{"repo":"NousResearch/hermes-agent","public_id":"4707","display_label":"Profile-scoped HERMES_HOME path isolation fixes","summary":"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.","subsystem":"cron","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53570},"resolution":{"state":"resolved","by":"pr-53570"},"freshness":{"state":"verified","last_verified_at":"2026-06-27T10:55:02+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":4707,"pr_number":53570,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":4707,"title":"[Bug]: cron under profile-scoped launchd gateway falls back to default `~/.hermes` instead of profile `HERMES_HOME`","url":null,"state":"closed"},{"kind":"pr","number":4729,"title":"fix(cron): resolve dynamic paths at runtime for profile isolation (#4707)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d73078e7b036ae75999481fc8ffaa2b82b69cf87","number":0},{"kind":"pr","value":"#4707","number":4707}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":5121,"title":"fix: respect pre-set HERMES_HOME in _apply_profile_override","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":38308,"title":"fix(agent): inject process HERMES_HOME into subprocess env when context override unset","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53570","number":53570}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":53570,"title":"fix(cron): make per-profile cron isolation intentional and tested (#4707)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/jobs.py","cron/scheduler.py","cron/suggestions.py","tests/cron/test_cron_profile_isolation.py"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -49,6 +49,18 @@\n # Configuration\n # =============================================================================\n \n+# Cron is per-profile by design (issue #4707). Each profile owns its own cron\n+# store under its own HERMES_HOME, and a profile-scoped gateway runs that\n+# profile's jobs under that same HERMES_HOME — so a job authored in profile\n+# `coder` lives in `~/.hermes/profiles/coder/cron/jobs.json` and executes with\n+# `coder`'s `.env`, `config.yaml`, and skills. We deliberately anchor on\n+# `get_hermes_home()` (the active profile home), NOT `get_default_hermes_root()`\n+# (the shared root). Anchoring at the root would funnel every profile's jobs\n+# into one shared `jobs.json` and run them under whatever HERMES_HOME the\n+# ticker process happens to have — leaking config/credentials/skills across\n+# profiles (the security boundary #4707 was filed for). Do NOT change this to\n+# the default root: that re-breaks per-profile isolation. See also the dynamic\n+# `_get_hermes_home()` / `_get_lock_paths()` resolution in cron/scheduler.py.\n HERMES_DIR = get_hermes_home().resolve()\n CRON_DIR = HERMES_DIR / \"cron\"\n JOBS_FILE = CRON_DIR / \"jobs.json\""]},{"file":"cron/scheduler.py","hunks":["@@ -355,7 +355,14 @@ def _shutdown_parallel_pool() -> None:\n \n \n def _get_hermes_home() -> Path:\n-    \"\"\"Resolve Hermes home dynamically while preserving test monkeypatch hooks.\"\"\"\n+    \"\"\"Resolve Hermes home dynamically while preserving test monkeypatch hooks.\n+\n+    Cron is per-profile by design (#4707): the in-process ticker runs inside a\n+    profile-scoped gateway, so resolving the active HERMES_HOME at call time\n+    means a profile's jobs are stored AND executed under that profile's home\n+    (its .env, config.yaml, scripts, skills). Do not freeze this at import or\n+    anchor it at the shared default root — either re-breaks profile isolation.\n+    \"\"\"\n     return _hermes_home or get_hermes_home()\n \n "]},{"file":"cron/suggestions.py","hunks":["@@ -42,6 +42,9 @@\n \n logger = logging.getLogger(__name__)\n \n+# Per-profile by design (issue #4707): suggestions live alongside the active\n+# profile's cron store. Anchor on get_hermes_home() (profile home), not the\n+# shared default root. See cron/jobs.py for the full rationale.\n CRON_DIR = get_hermes_home().resolve() / \"cron\"\n SUGGESTIONS_FILE = CRON_DIR / \"suggestions.json\"\n "]},{"file":"tests/cron/test_cron_profile_isolation.py","hunks":["@@ -0,0 +1,126 @@\n+\"\"\"Regression tests for #4707 — cron must be per-profile.\n+\n+Design intent (Teknium, June 2026): a profile's cron jobs both LIVE in that\n+profile's HERMES_HOME and EXECUTE under it.\n+\n+- Storage: a job created under profile ``coder`` writes to\n+  ``~/.hermes/profiles/coder/cron/jobs.json`` — NOT the shared default root.\n+- Execution: the profile-scoped gateway's in-process ticker resolves the\n+  active HERMES_HOME (profile home) at call time, so jobs run with that\n+  profile's ``.env`` / ``config.yaml`` / scripts / skills.\n+\n+This is the opposite direction from the (reverted) #50112/#32091 \"anchor at the\n+shared root\" approach. Anchoring at the root funnels every profile's jobs into\n+one store and runs them under whatever HERMES_HOME the ticker happens to have —\n+leaking config/credentials/skills across profiles, the security boundary #4707\n+was filed for. These tests pin per-profile isolation so a stale-branch merge or\n+a re-anchor \"fix\" can't silently flip it back.\n+\"\"\"\n+import importlib\n+from pathlib import Path\n+\n+\n+def _set_profile_env(monkeypatch, root: Path, profile_home: Path) -> None:\n+    \"\"\"Pretend the platform default root is ``root`` and the active\n+    HERMES_HOME is a profile under it (``<root>/profiles/<name>``).\"\"\"\n+    import hermes_constants\n+\n+    monkeypatch.setattr(\n+        hermes_constants, \"_get_platform_default_hermes_home\", lambda: root\n+    )\n+    monkeypatch.setenv(\"HERMES_HOME\", str(profile_home))\n+\n+\n+def test_cron_storage_anchors_at_profile_home(tmp_path, monkeypatch):\n+    \"\"\"Under a profile HERMES_HOME (<root>/profiles/<name>), the cron store\n+    resolves to <profile>/cron, NOT the shared <root>/cron.\"\"\"\n+    root = tmp_path / \"hermes_home\"\n+    profile_home = root / \"profiles\" / \"coder\"\n+    profile_home.mkdir(parents=True)\n+\n+    _set_profile_env(monkeypatch, root, profile_home)\n+\n+    import hermes_constants\n+\n+    # Sanity: the override is wired the way the gateway sees it.\n+    assert hermes_constants.get_hermes_home().resolve() == profile_home.resolve()\n+    assert hermes_constants.get_default_hermes_root().resolve() == root.resolve()\n+\n+    # cron/jobs.py computes HERMES_DIR from get_hermes_home() at import, so a\n+    # fresh import under this env anchors the store at <profile>/cron.\n+    import cron.jobs as jobs\n+\n+    importlib.reload(jobs)\n+    try:\n+        assert jobs.HERMES_DIR.resolve() == profile_home.resolve()\n+        assert (\n+            jobs.JOBS_FILE.resolve()\n+            == (profile_home / \"cron\" / \"jobs.json\").resolve()\n+        )\n+        # The shared-root path must NOT be the store — that would re-break\n+        # per-profile isolation (#4707).\n+        assert (\n+            jobs.JOBS_FILE.resolve() != (root / \"cron\" / \"jobs.json\").resolve()\n+        )\n+    finally:\n+        monkeypatch.undo()\n+        importlib.reload(jobs)\n+\n+\n+def test_cron_lock_path_anchors_at_profile_home(tmp_path, monkeypatch):\n+    \"\"\"The tick lock is also profile-scoped, so two profile gateways tick\n+    independently instead of contending on one shared lock.\"\"\"\n+    root = tmp_path / \"hermes_home\"\n+    profile_home = root / \"profiles\" / \"coder\"\n+    profile_home.mkdir(parents=True)\n+\n+    _set_profile_env(monkeypatch, root, profile_home)\n+\n+    import cron.scheduler as scheduler\n+\n+    lock_dir, lock_file = scheduler._get_lock_paths()\n+    assert lock_dir.resolve() == (profile_home / \"cron\").resolve()\n+    assert lock_file.resolve() == (profile_home / \"cron\" / \".tick.lock\").resolve()\n+    assert lock_dir.resolve() != (root / \"cron\").resolve()\n+\n+\n+def test_cron_execution_home_follows_active_profile(tmp_path, monkeypatch):\n+    \"\"\"Execution-time home resolution (.env / config.yaml / scripts) follows\n+    the active profile, not the shared root — so a profile gateway runs its\n+    jobs with that profile's runtime config.\"\"\"\n+    root = tmp_path / \"hermes_home\"\n+    profile_home = root / \"profiles\" / \"coder\"\n+    profile_home.mkdir(parents=True)\n+\n+    _set_profile_env(monkeypatch, root, profile_home)\n+\n+    import cron.scheduler as scheduler\n+\n+    # The module-level test override must be clear so the dynamic path runs.\n+    monkeypatch.setattr(scheduler, \"_hermes_home\", None, raising=False)\n+    assert scheduler._get_hermes_home().resolve() == profile_home.resolve()\n+    assert scheduler._get_hermes_home().resolve() != root.resolve()\n+\n+\n+def test_cron_storage_unaffected_when_no_profile(tmp_path, monkeypatch):\n+    \"\"\"With no profile (HERMES_HOME == root), the store is the root's cron dir\n+    — unchanged behavior for single-profile installs.\"\"\"\n+    root = tmp_path / \"hermes_home\"\n+    root.mkdir(parents=True)\n+\n+    import hermes_constants\n+\n+    monkeypatch.setattr(\n+        hermes_constants, \"_get_platform_default_hermes_home\", lambda: root\n+    )\n+    monkeypatch.setenv(\"HERMES_HOME\", str(root))\n+\n+    import cron.jobs as jobs\n+\n+    importlib.reload(jobs)\n+    try:\n+        assert jobs.HERMES_DIR.resolve() == root.resolve()\n+        assert jobs.JOBS_FILE.resolve() == (root / \"cron\" / \"jobs.json\").resolve()\n+    finally:\n+        monkeypatch.undo()\n+        importlib.reload(jobs)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}