Profile-scoped HERMES_HOME path isolation fixes
Cluster 4707 · cron. Data as of
Nothing open on GitHub. Fix merged in #53570. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #53570. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #53570. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple fixes addressing hardcoded ~/.hermes paths and HERMES_HOME environment variable not being respected when using profile overrides. Changes affect cron scheduling, agent subprocess environments, and profile model configuration.
Open issues0
Open PRs0
Fixes on record
-
#53570 fix(cron): make per-profile cron isolation intentional and tested (#4707)
files:
cron/jobs.py,cron/scheduler.py,cron/suggestions.py,tests/cron/test_cron_profile_isolation.pyShow the diff
-
cron/jobs.py@@ -49,6 +49,18 @@ # 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" -
cron/scheduler.py@@ -355,7 +355,14 @@ def _shutdown_parallel_pool() -> 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() -
cron/suggestions.py@@ -42,6 +42,9 @@ 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" -
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 (``<root>/profiles/<name>``).""" + 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 (<root>/profiles/<name>), the cron store + resolves to <profile>/cron, NOT the shared <root>/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 <profile>/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)
-
3 PRs closed without merging
From the close comments: done elsewhere 1 · superseded by another PR 1 · other 1
-
#4729 fix(cron): resolve dynamic paths at runtime for profile isolation (#4707) · done elsewhere →
d73078e,#4707 - #5121 fix: respect pre-set HERMES_HOME in _apply_profile_override · other
-
#38308 fix(agent): inject process HERMES_HOME into subprocess env when context override unset · superseded by another PR →
#53570
Closed issues · 1
- Closed#4707[Bug]: cron under profile-scoped launchd gateway falls back to default `~/.hermes` instead of profile `HERMES_HOME`
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- cron
- Merged PRs
- #53570
- Closed issues
- 1
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 27 Jun 2026