hermes - 💡(How to fix) Fix `cron/jobs.py` forces `jobs.json` to 0600 on every save, breaking shared WebUI/gateway container deployments [1 pull requests]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

cron/jobs.py currently hardcodes owner-only permissions (0600) for ~/.hermes/cron/jobs.json every time cron jobs are saved. This breaks multi-container deployments where the WebUI and gateway share the same Hermes home volume but run as different UIDs/GIDs.

In that setup, one process can successfully write jobs.json, but the next save resets the file to 0600, making it unreadable to the other container/user. This causes cron dashboard/API failures and makes the issue recur after any normal cron state write.

Error Message

cron/jobs.py

def save_jobs(jobs: List[Dict[str, Any]]): """Save all jobs to storage.""" ensure_dirs() 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: json.dump({"jobs": jobs, "updated_at": _hermes_now().isoformat()}, f, indent=2) f.flush() os.fsync(f.fileno()) atomic_replace(tmp_path, JOBS_FILE) _secure_file(JOBS_FILE) except BaseException: ...

Root Cause

cron/jobs.py currently hardcodes owner-only permissions (0600) for ~/.hermes/cron/jobs.json every time cron jobs are saved. This breaks multi-container deployments where the WebUI and gateway share the same Hermes home volume but run as different UIDs/GIDs.

In that setup, one process can successfully write jobs.json, but the next save resets the file to 0600, making it unreadable to the other container/user. This causes cron dashboard/API failures and makes the issue recur after any normal cron state write.

Fix Action

Fixed

Code Example

# cron/jobs.py

def _secure_file(path: Path):
    """Set file to owner-only read/write (0600). No-op on Windows."""
    try:
        if path.exists():
            os.chmod(path, 0o600)
    except (OSError, NotImplementedError):
        pass

---

# cron/jobs.py

def save_jobs(jobs: List[Dict[str, Any]]):
    """Save all jobs to storage."""
    ensure_dirs()
    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:
            json.dump({"jobs": jobs, "updated_at": _hermes_now().isoformat()}, f, indent=2)
            f.flush()
            os.fsync(f.fileno())
        atomic_replace(tmp_path, JOBS_FILE)
        _secure_file(JOBS_FILE)
    except BaseException:
        ...

---

def atomic_json_write(path, data, *, indent=2, **dump_kwargs):
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)

    original_mode = _preserve_file_mode(path)

    fd, tmp_path = tempfile.mkstemp(...)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(...)
            f.flush()
            os.fsync(f.fileno())

        real_path = atomic_replace(tmp_path, path)
        _restore_file_mode(real_path, original_mode)
    except BaseException:
        ...

---

from utils import atomic_json_write

def save_jobs(jobs: List[Dict[str, Any]]):
    ensure_dirs()
    atomic_json_write(
        JOBS_FILE,
        {"jobs": jobs, "updated_at": _hermes_now().isoformat()},
        indent=2,
    )

---

cron:
  jobs_file_mode: "0600"

---

cron:
  jobs_file_mode: "0664"

---

_secure_file(JOBS_FILE)

---

os.chmod(path, 0o600)
RAW_BUFFERClick to expand / collapse

Summary

cron/jobs.py currently hardcodes owner-only permissions (0600) for ~/.hermes/cron/jobs.json every time cron jobs are saved. This breaks multi-container deployments where the WebUI and gateway share the same Hermes home volume but run as different UIDs/GIDs.

In that setup, one process can successfully write jobs.json, but the next save resets the file to 0600, making it unreadable to the other container/user. This causes cron dashboard/API failures and makes the issue recur after any normal cron state write.

Exact code location

Current write path:

# cron/jobs.py

def _secure_file(path: Path):
    """Set file to owner-only read/write (0600). No-op on Windows."""
    try:
        if path.exists():
            os.chmod(path, 0o600)
    except (OSError, NotImplementedError):
        pass

save_jobs() calls this after every atomic replacement:

# cron/jobs.py

def save_jobs(jobs: List[Dict[str, Any]]):
    """Save all jobs to storage."""
    ensure_dirs()
    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:
            json.dump({"jobs": jobs, "updated_at": _hermes_now().isoformat()}, f, indent=2)
            f.flush()
            os.fsync(f.fileno())
        atomic_replace(tmp_path, JOBS_FILE)
        _secure_file(JOBS_FILE)
    except BaseException:
        ...

In the current checkout I inspected:

  • cron/jobs.py:145-150 defines _secure_file() and hardcodes 0o600
  • cron/jobs.py:433-443 defines save_jobs() and calls _secure_file(JOBS_FILE) after atomic_replace(...)

Why this is a problem

For single-user local CLI installs, 0600 is sensible.

But for Docker / NAS / WebUI deployments, it is common to have:

  • WebUI container running as one UID
  • gateway container running as another UID
  • both sharing the same Hermes home volume
  • group-readable/writable files, e.g. 0664, managed by volume ownership/ACLs

If either process saves cron state, Hermes rewrites jobs.json and then explicitly chmods it to 0600, overriding the deployment’s intended permissions. This is not a umask issue — the final permission is forced by _secure_file().

Observed failure mode:

  1. jobs.json is repaired to something like 1000:1000 + 0664
  2. WebUI and gateway can both read/write it
  3. A cron job runs or cron metadata updates
  4. save_jobs() writes via temp file + atomic replace
  5. _secure_file(JOBS_FILE) resets the file to 0600
  6. the other container UID can no longer read jobs.json
  7. cron API/dashboard/job management fails again

Existing project pattern that seems like the right fix

There is already a more deployment-friendly atomic JSON write helper in utils.py:

def atomic_json_write(path, data, *, indent=2, **dump_kwargs):
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)

    original_mode = _preserve_file_mode(path)

    fd, tmp_path = tempfile.mkstemp(...)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(...)
            f.flush()
            os.fsync(f.fileno())

        real_path = atomic_replace(tmp_path, path)
        _restore_file_mode(real_path, original_mode)
    except BaseException:
        ...

Relevant details:

  • utils.py:36-41 has _preserve_file_mode(...)
  • utils.py:44-58 has _restore_file_mode(...)
  • utils.py:85-128 has atomic_json_write(...)
  • it already preserves the existing file mode across temp-file replacement

save_jobs() could likely use this helper instead of reimplementing its own temp-file write and then forcing _secure_file().

For example, conceptually:

from utils import atomic_json_write

def save_jobs(jobs: List[Dict[str, Any]]):
    ensure_dirs()
    atomic_json_write(
        JOBS_FILE,
        {"jobs": jobs, "updated_at": _hermes_now().isoformat()},
        indent=2,
    )

That would preserve an existing 0664 when the deployment intentionally set it, while still creating new files safely.

Requested behavior

Please consider one of these options:

Option A: Preserve existing file mode

Make save_jobs() preserve the mode of an existing jobs.json, matching the current utils.atomic_json_write() behavior.

This would avoid surprising deployments that already set correct ownership/ACLs.

Option B: Add a config option

Add a config key such as:

cron:
  jobs_file_mode: "0600"

or:

cron:
  jobs_file_mode: "0664"

Default could remain 0600 for backwards-compatible local security, but Docker/WebUI/gateway deployments could explicitly opt into 0664.

Option C: Combine both

Use mode-preserving behavior by default, and allow cron.jobs_file_mode to override when explicitly configured.

Why a config option may still be useful

Mode-preserving behavior fixes the recurring reset once the file exists with the right permissions.

A config option would also solve first-write / recreated-file cases where jobs.json does not exist yet and the deployment needs group-readable/group-writable permissions from the start.

Expected result

A multi-container deployment sharing Hermes home should be able to configure or preserve jobs.json permissions so both WebUI and gateway processes can read/write cron jobs without cron state writes repeatedly reverting the file to 0600.

Actual result

Every save_jobs() call currently ends with:

_secure_file(JOBS_FILE)

which forces:

os.chmod(path, 0o600)

and breaks shared-UID/GID deployments.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - 💡(How to fix) Fix `cron/jobs.py` forces `jobs.json` to 0600 on every save, breaking shared WebUI/gateway container deployments [1 pull requests]