fix(cron): preserve jobs.json ownership on root rewrite + surface failing-tick reason

Running any state-writing `hermes cron` CLI command as root (the
default for `docker exec`) rewrote jobs.json via mkstemp +
atomic_replace, leaving it root:root mode 600. The gateway's ticker
(uid 1000 via PUID/PGID) was then locked out of every tick with
PermissionError — silently: the liveness heartbeat stayed fresh,
`hermes cron status` opened with 'Gateway is running — cron jobs will
fire automatically', and in the field ~14h of scheduled jobs were
skipped before a human noticed the absence of messages.

Fixes, per the issue's suggested items 1 and 3:

1. Ownership preservation on save (cron/jobs.py): snapshot the owner
   before the atomic replace; when the writer is privileged (euid 0)
   and the previous owner differs, chown the rewritten file back.
   First-time creation inherits the cron dir's owner. Unprivileged
   writers never call chown. POSIX-only (guarded via os.name/getattr),
   best-effort — a chown failure logs a warning but never breaks the
   save. 0600 hardening is unchanged.

2. Zombie-ticker surfacing: the ticker loop (both single-profile and
   multiplex paths) now persists the failure reason to a
   ticker_last_error marker next to the heartbeat files on every
   failed tick, and clears it on the next clean tick. `hermes cron
   status` shows the recorded reason in its 'ticks may be failing'
   branch, plus an actionable ownership hint when the error is a
   PermissionError (recommend `docker exec -u <uid>:<gid>`).

Fixes #68483
This commit is contained in:
teknium1 2026-07-24 12:54:45 -07:00 committed by Teknium
parent 939670cc4e
commit 722bf5d510
4 changed files with 458 additions and 2 deletions

View file

@ -475,6 +475,44 @@ def _secure_file(path: Path):
pass
def _preserve_file_ownership(path: Path, before: Optional[os.stat_result]) -> None:
"""Restore a rewritten file's previous owner (POSIX, privileged writer only).
The atomic-write pattern (mkstemp + replace) makes the rewritten file owned
by the *writer's* euid. When a root shell runs a state-writing cron CLI
command (``docker exec hermes hermes cron create ...`` ``docker exec``
defaults to root) against a store owned by the unprivileged gateway user,
the replace flips ``jobs.json`` to ``root:root`` mode 600 and the gateway's
ticker (uid 1000) is silently locked out of every subsequent tick (#68483).
Root can always hand ownership back, so do exactly that: when the euid is 0
and the pre-replace owner differs, chown the new file to the previous
uid/gid. Unprivileged writers are a no-op (their own rewrite already heals
a root-owned file back to their uid, and they couldn't chown anyway).
No-op on Windows. Best-effort: a failure must never break the save.
"""
if before is None or os.name != "posix":
return
geteuid = getattr(os, "geteuid", None)
getegid = getattr(os, "getegid", None)
if geteuid is None or getegid is None:
return
try:
euid = geteuid()
if euid != 0:
return # unprivileged writer — nothing to (or we could) restore
if (before.st_uid, before.st_gid) == (euid, getegid()):
return # already ours before the rewrite — nothing changed
os.chown(path, before.st_uid, before.st_gid)
except OSError as e:
logger.warning(
"Could not restore ownership of %s to uid=%s gid=%s after rewrite: %s "
"— if the gateway runs as a different user, its cron ticker may now "
"be locked out (see issue #68483).",
path, before.st_uid, before.st_gid, e,
)
def ensure_dirs():
"""Ensure cron directories exist with secure permissions."""
store = _current_cron_store()
@ -867,6 +905,64 @@ def get_ticker_success_age() -> Optional[float]:
return _epoch_file_age(store.cron_dir / "ticker_last_success")
def record_ticker_error(message: str) -> None:
"""Persist the most recent tick failure so other processes can surface it.
The ticker thread lives inside the gateway process; ``hermes cron
status``/``list`` run in a separate process and previously could only
infer "ticks may be failing" from marker staleness, with no clue WHY.
A root-owned ``jobs.json`` (#68483) failed every tick for ~14h with the
reason visible only in the gateway's errors.log. Writing the last error
next to the heartbeat markers gives the CLI something concrete to show.
Best-effort: a write failure must never disrupt the tick loop.
"""
store = _current_cron_store()
path = store.cron_dir / "ticker_last_error"
try:
ensure_dirs()
fd, tmp_path = tempfile.mkstemp(
dir=str(path.parent), suffix=".tmp", prefix=".terr_"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(f"{time.time()}\n{message.strip()}\n")
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
except Exception:
pass
def clear_ticker_error() -> None:
"""Remove the last-tick-error marker after a successful tick. Best-effort."""
store = _current_cron_store()
try:
(store.cron_dir / "ticker_last_error").unlink()
except OSError:
pass
def get_ticker_last_error() -> Optional[str]:
"""Return the most recent recorded tick error message, or None."""
store = _current_cron_store()
try:
raw = (store.cron_dir / "ticker_last_error").read_text(encoding="utf-8")
except Exception:
return None
lines = raw.splitlines()
if len(lines) < 2:
return None
message = "\n".join(lines[1:]).strip()
return message or None
# =============================================================================
# Job CRUD Operations
# =============================================================================
@ -927,6 +1023,19 @@ def _save_jobs_unlocked(jobs: List[Dict[str, Any]]):
"""Save all jobs to storage. Caller must hold _jobs_lock()."""
jobs_file = _current_cron_store().jobs_file
ensure_dirs()
# Snapshot the current owner BEFORE the atomic replace so a privileged
# writer (root CLI in Docker) can hand ownership back to the gateway user
# afterwards instead of locking its ticker out (#68483). When the file is
# being created for the first time, inherit the cron dir's owner — in the
# Docker image that is the PUID/PGID gateway user who must be able to
# read the store on the next tick.
try:
_stat_before = os.stat(jobs_file)
except OSError:
try:
_stat_before = os.stat(jobs_file.parent)
except OSError:
_stat_before = None
fd, tmp_path = tempfile.mkstemp(dir=str(jobs_file.parent), suffix='.tmp', prefix='.jobs_')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
@ -935,6 +1044,7 @@ def _save_jobs_unlocked(jobs: List[Dict[str, Any]]):
os.fsync(f.fileno())
atomic_replace(tmp_path, jobs_file)
_secure_file(jobs_file)
_preserve_file_ownership(jobs_file, _stat_before)
except BaseException:
try:
os.unlink(tmp_path)

View file

@ -185,7 +185,11 @@ class InProcessCronScheduler(CronScheduler):
):
import logging
from cron.scheduler import tick as cron_tick
from cron.jobs import record_ticker_heartbeat
from cron.jobs import (
clear_ticker_error,
record_ticker_error,
record_ticker_heartbeat,
)
logger = logging.getLogger("cron.scheduler_provider")
logger.info("In-process cron scheduler started (interval=%ds)", interval)
@ -241,10 +245,19 @@ class InProcessCronScheduler(CronScheduler):
# an exception in this daemon thread, so swallowing it and
# re-checking stop_event keeps shutdown clean.
logger.error("Cron tick error: %s", e, exc_info=True)
# Persist the failure reason next to the heartbeat markers so
# `hermes cron status`/`list` (separate processes) can show
# WHY ticks fail, not just that the success marker is stale —
# e.g. a root-rewritten jobs.json locking out the ticker's
# uid went unnoticed for ~14h with the reason buried in the
# gateway log (#68483).
record_ticker_error(f"{type(e).__name__}: {e}")
# Record liveness every iteration; bump the success marker only on a
# clean tick, so status can tell "alive but failing every tick" from
# "actually firing jobs" (#32612, #32895).
record_ticker_heartbeat(success=ok)
if ok:
clear_ticker_error()
stop_event.wait(interval)
def _start_multiplex(
@ -267,7 +280,12 @@ class InProcessCronScheduler(CronScheduler):
"""
import logging
from cron.scheduler import tick as cron_tick
from cron.jobs import record_ticker_heartbeat, use_cron_store
from cron.jobs import (
clear_ticker_error,
record_ticker_error,
record_ticker_heartbeat,
use_cron_store,
)
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
logger = logging.getLogger("cron.scheduler_provider")
@ -317,6 +335,9 @@ class InProcessCronScheduler(CronScheduler):
ok = True
except BaseException as e:
logger.error("Cron tick error: %s", e, exc_info=True)
_tick_error = f"{type(e).__name__}: {e}"
else:
_tick_error = None
# Record per-profile heartbeat after each tick cycle.
for entry in profile_homes:
home = entry[1] if isinstance(entry, tuple) else entry
@ -324,6 +345,13 @@ class InProcessCronScheduler(CronScheduler):
try:
with use_cron_store(home):
record_ticker_heartbeat(success=ok)
# Surface the failure reason (or clear it) per profile
# so `hermes cron status` can show WHY ticks fail
# (#68483).
if ok:
clear_ticker_error()
elif _tick_error:
record_ticker_error(_tick_error)
finally:
reset_hermes_home_override(home_token)
stop_event.wait(interval)

View file

@ -254,6 +254,7 @@ def cron_status():
# (#32612, #32895).
from cron.jobs import (
get_ticker_heartbeat_age,
get_ticker_last_error,
get_ticker_success_age,
TICKER_INTERVAL_SECONDS,
)
@ -283,6 +284,20 @@ def cron_status():
Colors.YELLOW,
))
print(f" PID: {', '.join(map(str, pids))}")
last_error = get_ticker_last_error()
if last_error:
# Show WHY ticks fail — e.g. a root-rewritten jobs.json
# (PermissionError) that silently locked out the ticker's
# uid for ~14h in the field (#68483).
print(color(f" Last tick error: {last_error}", Colors.RED))
if "Permission denied" in last_error:
print(color(
" Hint: jobs.json may be owned by another user "
"(e.g. rewritten by a root `docker exec hermes "
"hermes cron ...`). Fix ownership to match the "
"gateway user, and prefer `docker exec -u <uid>:<gid>`.",
Colors.YELLOW,
))
print(" Check the gateway log for 'Cron tick error'.")
else:
print(color("✓ Gateway is running — cron jobs will fire automatically", Colors.GREEN))

View file

@ -0,0 +1,303 @@
"""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_root_first_write_inherits_cron_dir_owner(self, cron_store, monkeypatch):
"""Creating jobs.json for the first time as root must inherit the
cron dir's owner (the gateway user in the Docker image)."""
chown_calls = []
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(cron_store):
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))
)
assert not jobs_file.exists()
jobs.save_jobs([{"id": "new", "prompt": "hello"}])
assert chown_calls == [(str(jobs_file), 1000, 1000)]
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
def test_clear_when_absent_is_noop(self, cron_store):
jobs.clear_ticker_error() # must not raise
assert jobs.get_ticker_last_error() is None
def test_record_failure_is_silent(self, tmp_path, monkeypatch):
"""Marker write failure must never disrupt the tick loop."""
blocker = tmp_path / "not_a_dir"
blocker.write_text("i am a file")
bad_dir = blocker / "cron"
monkeypatch.setattr(jobs, "CRON_DIR", bad_dir)
monkeypatch.setattr(jobs, "JOBS_FILE", bad_dir / "jobs.json")
monkeypatch.setattr(jobs, "OUTPUT_DIR", bad_dir / "output")
jobs.record_ticker_error("RuntimeError: boom") # must not raise
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
def test_status_without_marker_keeps_generic_message(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)
monkeypatch.setattr(jobs, "get_ticker_success_age", lambda: 9_999.0)
monkeypatch.setattr(jobs, "get_ticker_last_error", lambda: None)
monkeypatch.setattr("cron.jobs.list_jobs", lambda **k: [])
cron_cli.cron_status()
out = capsys.readouterr().out
assert "no tick has succeeded" in out
assert "Last tick error:" not in out