From d73078e7b036ae75999481fc8ffaa2b82b69cf87 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 27 Jun 2026 03:55:01 -0700 Subject: [PATCH] fix(cron): make per-profile cron isolation intentional and tested (#4707) (#53570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A profile's cron jobs now provably live in AND execute under that profile's HERMES_HOME. A job authored under profile `coder` is stored at `~/.hermes/profiles/coder/cron/jobs.json` and runs with coder's .env, config.yaml, scripts and skills — never the default root's. This was the de-facto behavior on main but only by accident: PR #50112 had re-anchored cron storage at the shared default root, and a later stale-branch squash merge (#52147) silently reverted it back to the profile home. Neither direction was guarded by a test, so it could flip again on the next stale merge. Changes: - cron/jobs.py: document the per-profile storage anchor (get_hermes_home, NOT get_default_hermes_root) and why anchoring at the root leaks config/credentials/skills across profiles — the #4707 security boundary. - cron/scheduler.py, cron/suggestions.py: same intent documented at the dynamic resolution helper and the suggestions store. - tests/cron/test_cron_profile_isolation.py: pin storage, lock-path, and execution-home resolution to the active profile so a re-anchor can't regress. Verified E2E: jobs created under two profiles land in separate per-profile stores with zero cross-profile leakage and no shared-root store; scheduler execution-home follows the active profile. Full cron suite: 576/576. --- cron/jobs.py | 12 +++ cron/scheduler.py | 9 +- cron/suggestions.py | 3 + tests/cron/test_cron_profile_isolation.py | 126 ++++++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 tests/cron/test_cron_profile_isolation.py diff --git a/cron/jobs.py b/cron/jobs.py index 0c10c65c9fb..e9ab8939fed 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -49,6 +49,18 @@ except ImportError: # Configuration # ============================================================================= +# Cron is per-profile by design (issue #4707). Each profile owns its own cron +# store under its own HERMES_HOME, and a profile-scoped gateway runs that +# profile's jobs under that same HERMES_HOME — so a job authored in profile +# `coder` lives in `~/.hermes/profiles/coder/cron/jobs.json` and executes with +# `coder`'s `.env`, `config.yaml`, and skills. We deliberately anchor on +# `get_hermes_home()` (the active profile home), NOT `get_default_hermes_root()` +# (the shared root). Anchoring at the root would funnel every profile's jobs +# into one shared `jobs.json` and run them under whatever HERMES_HOME the +# ticker process happens to have — leaking config/credentials/skills across +# profiles (the security boundary #4707 was filed for). Do NOT change this to +# the default root: that re-breaks per-profile isolation. See also the dynamic +# `_get_hermes_home()` / `_get_lock_paths()` resolution in cron/scheduler.py. HERMES_DIR = get_hermes_home().resolve() CRON_DIR = HERMES_DIR / "cron" JOBS_FILE = CRON_DIR / "jobs.json" diff --git a/cron/scheduler.py b/cron/scheduler.py index cfa2a702466..e469fddcd53 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -355,7 +355,14 @@ _hermes_home: Path | None = None def _get_hermes_home() -> Path: - """Resolve Hermes home dynamically while preserving test monkeypatch hooks.""" + """Resolve Hermes home dynamically while preserving test monkeypatch hooks. + + Cron is per-profile by design (#4707): the in-process ticker runs inside a + profile-scoped gateway, so resolving the active HERMES_HOME at call time + means a profile's jobs are stored AND executed under that profile's home + (its .env, config.yaml, scripts, skills). Do not freeze this at import or + anchor it at the shared default root — either re-breaks profile isolation. + """ return _hermes_home or get_hermes_home() diff --git a/cron/suggestions.py b/cron/suggestions.py index 636a0335cc3..6826c2d8d3b 100644 --- a/cron/suggestions.py +++ b/cron/suggestions.py @@ -42,6 +42,9 @@ from utils import atomic_replace logger = logging.getLogger(__name__) +# Per-profile by design (issue #4707): suggestions live alongside the active +# profile's cron store. Anchor on get_hermes_home() (profile home), not the +# shared default root. See cron/jobs.py for the full rationale. CRON_DIR = get_hermes_home().resolve() / "cron" SUGGESTIONS_FILE = CRON_DIR / "suggestions.json" diff --git a/tests/cron/test_cron_profile_isolation.py b/tests/cron/test_cron_profile_isolation.py new file mode 100644 index 00000000000..bae8e5fe759 --- /dev/null +++ b/tests/cron/test_cron_profile_isolation.py @@ -0,0 +1,126 @@ +"""Regression tests for #4707 — cron must be per-profile. + +Design intent (Teknium, June 2026): a profile's cron jobs both LIVE in that +profile's HERMES_HOME and EXECUTE under it. + +- Storage: a job created under profile ``coder`` writes to + ``~/.hermes/profiles/coder/cron/jobs.json`` — NOT the shared default root. +- Execution: the profile-scoped gateway's in-process ticker resolves the + active HERMES_HOME (profile home) at call time, so jobs run with that + profile's ``.env`` / ``config.yaml`` / scripts / skills. + +This is the opposite direction from the (reverted) #50112/#32091 "anchor at the +shared root" approach. Anchoring at the root funnels every profile's jobs into +one store and runs them under whatever HERMES_HOME the ticker happens to have — +leaking config/credentials/skills across profiles, the security boundary #4707 +was filed for. These tests pin per-profile isolation so a stale-branch merge or +a re-anchor "fix" can't silently flip it back. +""" +import importlib +from pathlib import Path + + +def _set_profile_env(monkeypatch, root: Path, profile_home: Path) -> None: + """Pretend the platform default root is ``root`` and the active + HERMES_HOME is a profile under it (``/profiles/``).""" + import hermes_constants + + monkeypatch.setattr( + hermes_constants, "_get_platform_default_hermes_home", lambda: root + ) + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + + +def test_cron_storage_anchors_at_profile_home(tmp_path, monkeypatch): + """Under a profile HERMES_HOME (/profiles/), the cron store + resolves to /cron, NOT the shared /cron.""" + root = tmp_path / "hermes_home" + profile_home = root / "profiles" / "coder" + profile_home.mkdir(parents=True) + + _set_profile_env(monkeypatch, root, profile_home) + + import hermes_constants + + # Sanity: the override is wired the way the gateway sees it. + assert hermes_constants.get_hermes_home().resolve() == profile_home.resolve() + assert hermes_constants.get_default_hermes_root().resolve() == root.resolve() + + # cron/jobs.py computes HERMES_DIR from get_hermes_home() at import, so a + # fresh import under this env anchors the store at /cron. + import cron.jobs as jobs + + importlib.reload(jobs) + try: + assert jobs.HERMES_DIR.resolve() == profile_home.resolve() + assert ( + jobs.JOBS_FILE.resolve() + == (profile_home / "cron" / "jobs.json").resolve() + ) + # The shared-root path must NOT be the store — that would re-break + # per-profile isolation (#4707). + assert ( + jobs.JOBS_FILE.resolve() != (root / "cron" / "jobs.json").resolve() + ) + finally: + monkeypatch.undo() + importlib.reload(jobs) + + +def test_cron_lock_path_anchors_at_profile_home(tmp_path, monkeypatch): + """The tick lock is also profile-scoped, so two profile gateways tick + independently instead of contending on one shared lock.""" + root = tmp_path / "hermes_home" + profile_home = root / "profiles" / "coder" + profile_home.mkdir(parents=True) + + _set_profile_env(monkeypatch, root, profile_home) + + import cron.scheduler as scheduler + + lock_dir, lock_file = scheduler._get_lock_paths() + assert lock_dir.resolve() == (profile_home / "cron").resolve() + assert lock_file.resolve() == (profile_home / "cron" / ".tick.lock").resolve() + assert lock_dir.resolve() != (root / "cron").resolve() + + +def test_cron_execution_home_follows_active_profile(tmp_path, monkeypatch): + """Execution-time home resolution (.env / config.yaml / scripts) follows + the active profile, not the shared root — so a profile gateway runs its + jobs with that profile's runtime config.""" + root = tmp_path / "hermes_home" + profile_home = root / "profiles" / "coder" + profile_home.mkdir(parents=True) + + _set_profile_env(monkeypatch, root, profile_home) + + import cron.scheduler as scheduler + + # The module-level test override must be clear so the dynamic path runs. + monkeypatch.setattr(scheduler, "_hermes_home", None, raising=False) + assert scheduler._get_hermes_home().resolve() == profile_home.resolve() + assert scheduler._get_hermes_home().resolve() != root.resolve() + + +def test_cron_storage_unaffected_when_no_profile(tmp_path, monkeypatch): + """With no profile (HERMES_HOME == root), the store is the root's cron dir + — unchanged behavior for single-profile installs.""" + root = tmp_path / "hermes_home" + root.mkdir(parents=True) + + import hermes_constants + + monkeypatch.setattr( + hermes_constants, "_get_platform_default_hermes_home", lambda: root + ) + monkeypatch.setenv("HERMES_HOME", str(root)) + + import cron.jobs as jobs + + importlib.reload(jobs) + try: + assert jobs.HERMES_DIR.resolve() == root.resolve() + assert jobs.JOBS_FILE.resolve() == (root / "cron" / "jobs.json").resolve() + finally: + monkeypatch.undo() + importlib.reload(jobs)