# NousResearch/hermes-agent · Profile-scoped HERMES_HOME path isolation fixes

## Open now

- 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.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #53570. Verify it addresses your case.
- **Recommended:** read PR #53570 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-53570`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member 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.

## Closed / duplicate issues

- #4707: \[Bug\]: cron under profile-scoped launchd gateway falls back to default \`~/.hermes\` instead of profile \`HERMES_HOME\`

## Prior & competing PRs

- **#5121: fix: respect pre-set HERMES_HOME in _apply_profile_override**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#53570: fix(cron): make per-profile cron isolation intentional and tested (#4707)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cron/jobs.py`, `cron/scheduler.py`, `cron/suggestions.py`, `tests/cron/test_cron_profile_isolation.py`
  - fix hunks:
  - `cron/jobs.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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)
    ```
- **#4729: fix(cron): resolve dynamic paths at runtime for profile isolation (#4707)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `d73078e7b036ae75999481fc8ffaa2b82b69cf87`, `#4707`
- **#38308: fix(agent): inject process HERMES_HOME into subprocess env when context override unset**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#53570`

## Fix status

- Merged to main in PR #53570 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-27`.
- Subsystem: `cron`
