NousResearch/hermes-agent

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

0open issues
0open PRs
5items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #53570 fix(cron): make per-profile cron isolation intentional and tested (#4707)

    merged to main

    files: cron/jobs.py, cron/scheduler.py, cron/suggestions.py, tests/cron/test_cron_profile_isolation.py

    Show 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.00 means 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 Lab

At a glance

Subsystem
cron
Merged PRs
#53570
Closed issues
1
Attempts
3 PRs closed without merging
Fix link checked
27 Jun 2026

For your agent

The same facts, typed.