{"repo":"NousResearch/hermes-agent","public_id":"26066","display_label":"Dashboard multi-profile scoping and config writes","summary":"These items address how the dashboard handles per-profile configuration, environment, and platform writes, including adding profile scope selectors, propagating profile context to backend endpoints, and fixing writes that incorrectly target the default or wrong profile.","subsystem":"dashboard","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":44792},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-12T09:09:29+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.24799999999999997},"fix_edges":[{"issue_number":44193,"pr_number":44792,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":26066,"title":"[Feature]: Dashboard Sessions/Config/Keys tabs need a `?profile=` scope selector so one dashboard process can view & edit per-profile state","url":null,"state":"closed"},{"kind":"issue","number":44193,"title":"Multi-profile dashboard: config/env/platform writes always target the launch profile","url":null,"state":"closed"},{"kind":"issue","number":66406,"title":"[Bug]: Dashboard model change with --open-profile writes to default profile config (HERMES_HOME not propagated, #18594 family)","url":null,"state":"closed"},{"kind":"pr","number":44409,"title":"fix(dashboard): ensure messaging platform writes target active profile","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"88dbf95105644efb067500136c4a73277d3936d4","number":0},{"kind":"pr","value":"#44792","number":44792}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":44792,"title":"fix(dashboard): profile-scope Channels endpoints and seed per-profile .env","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/profiles.py","hermes_cli/web_server.py","tests/hermes_cli/test_profiles.py","tests/hermes_cli/test_web_server_messaging_profiles.py","web/src/lib/api.ts"],"fix_hunks":[{"file":"hermes_cli/profiles.py","hunks":["@@ -835,6 +835,25 @@ def create_profile(\n                     dst.parent.mkdir(parents=True, exist_ok=True)\n                     shutil.copy2(src, dst)\n \n+    # Seed an empty .env so the profile has its own credentials file from\n+    # day one. Without it, profile-scoped env writes (dashboard Channels /\n+    # Keys pages, `hermes -p <name> auth add`) had no file until first\n+    # write, and the profile silently inherited API keys from the shell\n+    # environment — users reasonably read that as \"the new profile reads\n+    # the root .env\". Skipped when --clone/--clone-all already copied one.\n+    env_path = profile_dir / \".env\"\n+    if not env_path.exists():\n+        try:\n+            env_path.write_text(\n+                \"# Per-profile secrets for this Hermes profile.\\n\"\n+                \"# API keys and tokens set here override the shell environment.\\n\"\n+                \"# Behavioral settings belong in config.yaml, not here.\\n\",\n+                encoding=\"utf-8\",\n+            )\n+            os.chmod(str(env_path), 0o600)\n+        except OSError:\n+            pass  # best-effort — save_env_value creates the file on demand\n+\n     # Seed a default SOUL.md so the user has a file to customize immediately.\n     # Skipped when the profile already has one (from --clone / --clone-all).\n     soul_path = profile_dir / \"SOUL.md\""]},{"file":"hermes_cli/web_server.py","hunks":["@@ -654,6 +654,9 @@ class MessagingPlatformUpdate(BaseModel):\n     enabled: Optional[bool] = None\n     env: Dict[str, str] = {}\n     clear_env: List[str] = []\n+    # Explicit body profile beats the query param injected by the global\n+    # dashboard profile switcher (same precedence as other scoped writes).\n+    profile: Optional[str] = None\n \n \n class TelegramOnboardingStart(BaseModel):","@@ -4209,7 +4212,10 @@ def _gateway_platform_config(platform_id: str):\n \n \n def _messaging_platform_payload(\n-    entry: dict[str, Any], env_on_disk: dict[str, str], runtime: dict | None\n+    entry: dict[str, Any],\n+    env_on_disk: dict[str, str],\n+    runtime: dict | None,\n+    scoped: bool = False,\n ) -> dict[str, Any]:\n     platform_id = entry[\"id\"]\n     gateway_running = get_running_pid() is not None","@@ -4222,7 +4228,11 @@ def _messaging_platform_payload(\n     env_vars = []\n \n     for key in entry[\"env_vars\"]:\n-        value = env_on_disk.get(key) or os.getenv(key, \"\")\n+        # When profile-scoped, judge only the profile's own .env — the\n+        # dashboard process's os.environ carries the ROOT install's .env\n+        # (loaded at startup) and would falsely report the root credentials\n+        # as the profile's.\n+        value = env_on_disk.get(key) or (\"\" if scoped else os.getenv(key, \"\"))\n         env_vars.append(\n             {\n                 \"key\": key,","@@ -4233,26 +4243,46 @@ def _messaging_platform_payload(\n             }\n         )\n \n-    try:\n-        gateway_config, platform, platform_config = _gateway_platform_config(\n-            platform_id\n-        )\n-        enabled = bool(platform_config and platform_config.enabled)\n-        configured = bool(\n-            platform_config\n-            and gateway_config._is_platform_connected(platform, platform_config)\n-        )\n-        home_channel = (\n-            platform_config.home_channel.to_dict()\n-            if platform_config and platform_config.home_channel\n-            else None\n-        )\n-    except Exception:\n-        enabled = False\n-        configured = all(\n-            env_on_disk.get(key) or os.getenv(key, \"\") for key in entry[\"required_env\"]\n-        )\n-        home_channel = None\n+    if scoped:\n+        # Profile-scoped view: derive enablement/configuration from the\n+        # profile's config.yaml + .env only. load_gateway_config()'s\n+        # env-override layer reads os.environ and would leak the root\n+        # install's tokens into the profile's reported state.\n+        try:\n+            cfg = load_config()\n+            platforms_cfg = cfg.get(\"platforms\") or {}\n+            plat_cfg = platforms_cfg.get(platform_id)\n+            if not isinstance(plat_cfg, dict):\n+                plat_cfg = {}\n+            enabled = bool(plat_cfg.get(\"enabled\"))\n+            hc = plat_cfg.get(\"home_channel\")\n+            home_channel = hc if isinstance(hc, dict) else None\n+        except Exception:\n+            enabled = False\n+            home_channel = None\n+        configured = all(env_on_disk.get(key) for key in entry[\"required_env\"])\n+    else:\n+        try:\n+            gateway_config, platform, platform_config = _gateway_platform_config(\n+                platform_id\n+            )\n+            enabled = bool(platform_config and platform_config.enabled)\n+            configured = bool(\n+                platform_config\n+                and gateway_config._is_platform_connected(platform, platform_config)\n+            )\n+            home_channel = (\n+                platform_config.home_channel.to_dict()\n+                if platform_config and platform_config.home_channel\n+                else None\n+            )\n+        except Exception:\n+            enabled = False\n+            configured = all(\n+                env_on_disk.get(key) or os.getenv(key, \"\")\n+                for key in entry[\"required_env\"]\n+            )\n+            home_channel = None\n \n     state = (\n         runtime_platform.get(\"state\") if isinstance(runtime_platform, dict) else None","@@ -4675,19 +4705,28 @@ async def cancel_telegram_onboarding(pairing_id: str):\n \n \n @app.get(\"/api/messaging/platforms\")\n-async def get_messaging_platforms():\n-    env_on_disk = load_env()\n-    runtime = read_runtime_status()\n-    return {\n-        \"platforms\": [\n-            _messaging_platform_payload(entry, env_on_disk, runtime)\n-            for entry in _messaging_platform_catalog()\n-        ]\n-    }\n+async def get_messaging_platforms(profile: Optional[str] = None):\n+    # Profile-scoped so the dashboard's global profile switcher shows the\n+    # TARGET profile's channel credentials/state, not the root install's.\n+    # Inside _profile_scope, load_env()/read_runtime_status()/get_running_pid()\n+    # all resolve against the requested profile's HERMES_HOME.\n+    with _profile_scope(profile) as scoped_dir:\n+        env_on_disk = load_env()\n+        runtime = read_runtime_status()\n+        return {\n+            \"platforms\": [\n+                _messaging_platform_payload(\n+                    entry, env_on_disk, runtime, scoped=scoped_dir is not None\n+                )\n+                for entry in _messaging_platform_catalog()\n+            ]\n+        }\n \n \n @app.put(\"/api/messaging/platforms/{platform_id}\")\n-async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpdate):\n+async def update_messaging_platform(\n+    platform_id: str, body: MessagingPlatformUpdate, profile: Optional[str] = None\n+):\n     entry = _catalog_lookup(platform_id)\n     if not entry:\n         raise HTTPException(","@@ -4696,26 +4735,27 @@ async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpd\n \n     allowed_env = set(entry[\"env_vars\"])\n     try:\n-        for key in body.clear_env:\n-            if key not in allowed_env:\n-                raise HTTPException(\n-                    status_code=400,\n-                    detail=f\"{key} is not configurable for {entry['name']}\",\n-                )\n-            remove_env_value(key)\n+        with _profile_scope(body.profile or profile):\n+            for key in body.clear_env:\n+                if key not in allowed_env:\n+                    raise HTTPException(\n+                        status_code=400,\n+                        detail=f\"{key} is not configurable for {entry['name']}\",\n+                    )\n+                remove_env_value(key)\n \n-        for key, value in body.env.items():\n-            if key not in allowed_env:\n-                raise HTTPException(\n-                    status_code=400,\n-                    detail=f\"{key} is not configurable for {entry['name']}\",\n-                )\n-            trimmed = value.strip()\n-            if trimmed:\n-                save_env_value(key, trimmed)\n+            for key, value in body.env.items():\n+                if key not in allowed_env:\n+                    raise HTTPException(\n+                        status_code=400,\n+                        detail=f\"{key} is not configurable for {entry['name']}\",\n+                    )\n+                trimmed = value.strip()\n+                if trimmed:\n+                    save_env_value(key, trimmed)\n \n-        if body.enabled is not None:\n-            _write_platform_enabled(platform_id, body.enabled)\n+            if body.enabled is not None:\n+                _write_platform_enabled(platform_id, body.enabled)\n \n         return {\"ok\": True, \"platform\": platform_id}\n     except HTTPException:","@@ -4726,15 +4766,18 @@ async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpd\n \n \n @app.post(\"/api/messaging/platforms/{platform_id}/test\")\n-async def test_messaging_platform(platform_id: str):\n+async def test_messaging_platform(platform_id: str, profile: Optional[str] = None):\n     entry = _catalog_lookup(platform_id)\n     if not entry:\n         raise HTTPException(\n             status_code=404, detail=f\"Unknown messaging platform: {platform_id}\"\n         )\n \n-    env_on_disk = load_env()\n-    payload = _messaging_platform_payload(entry, env_on_disk, read_runtime_status())\n+    with _profile_scope(profile) as scoped_dir:\n+        env_on_disk = load_env()\n+        payload = _messaging_platform_payload(\n+            entry, env_on_disk, read_runtime_status(), scoped=scoped_dir is not None\n+        )\n     if not payload[\"enabled\"]:\n         message = f\"{entry['name']} is disabled. Enable it, then restart the gateway.\"\n         return {\"ok\": False, \"state\": payload[\"state\"], \"message\": message}"]},{"file":"tests/hermes_cli/test_profiles.py","hunks":["@@ -158,6 +158,30 @@ def test_creates_directory_with_subdirs(self, profile_env):\n                         \"plans\", \"workspace\", \"cron\"]:\n             assert (profile_dir / subdir).is_dir(), f\"Missing subdir: {subdir}\"\n \n+    def test_seeds_placeholder_env_file(self, profile_env):\n+        \"\"\"Fresh profiles get their own .env (owner-only) so channel/env\n+        writes are profile-scoped from day one instead of falling through\n+        to the shell environment / root install.\"\"\"\n+        import stat\n+        profile_dir = create_profile(\"coder\", no_alias=True)\n+        env_path = profile_dir / \".env\"\n+        assert env_path.exists()\n+        content = env_path.read_text(encoding=\"utf-8\")\n+        # Placeholder only — no credentials leak in from anywhere.\n+        assert all(\n+            line.startswith(\"#\") or not line.strip()\n+            for line in content.splitlines()\n+        )\n+        mode = stat.S_IMODE(env_path.stat().st_mode)\n+        assert mode == 0o600\n+\n+    def test_seeded_env_does_not_clobber_cloned_env(self, profile_env):\n+        tmp_path = profile_env\n+        default_home = tmp_path / \".hermes\"\n+        (default_home / \".env\").write_text(\"KEY=val\")\n+        profile_dir = create_profile(\"coder\", clone_config=True, no_alias=True)\n+        assert (profile_dir / \".env\").read_text() == \"KEY=val\"\n+\n     def test_duplicate_raises_file_exists(self, profile_env):\n         create_profile(\"coder\", no_alias=True)\n         with pytest.raises(FileExistsError):","@@ -304,7 +328,9 @@ def test_clone_config_missing_files_skipped(self, profile_env):\n         profile_dir = create_profile(\"coder\", clone_config=True, no_alias=True)\n         # No error; optional files just not copied\n         assert not (profile_dir / \"config.yaml\").exists()\n-        assert not (profile_dir / \".env\").exists()\n+        # .env is always seeded (placeholder) so the profile has its own\n+        # credentials file even when the clone source lacked one.\n+        assert (profile_dir / \".env\").exists()\n         # SOUL.md is always seeded with the default even when clone source lacks it\n         assert (profile_dir / \"SOUL.md\").exists()\n "]},{"file":"tests/hermes_cli/test_web_server_messaging_profiles.py","hunks":["@@ -0,0 +1,182 @@\n+\"\"\"Regression tests for profile-scoped dashboard Channels endpoints.\n+\n+Before the ``profile`` parameter existed, ``/api/messaging/platforms`` always\n+read/wrote the dashboard process's own (root) ``.env`` via ``load_env()`` /\n+``save_env_value()`` — so a dashboard switched to a freshly created profile\n+still displayed and persisted the ROOT install's messaging credentials.\n+These tests pin the new behavior: reads and writes land in the REQUESTED\n+profile's HERMES_HOME, and the dashboard's own profile stays untouched.\n+\"\"\"\n+import pytest\n+import yaml\n+\n+\n[redacted:email]\n+def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):\n+    \"\"\"Isolated default home + one named profile, each with its own .env.\"\"\"\n+    from hermes_constants import get_hermes_home\n+    from hermes_cli import profiles\n+\n+    default_home = get_hermes_home()\n+    profiles_root = default_home / \"profiles\"\n+    worker_home = profiles_root / \"worker_alpha\"\n+    for home in (default_home, worker_home):\n+        home.mkdir(parents=True, exist_ok=True)\n+        (home / \"config.yaml\").write_text(\"{}\\n\", encoding=\"utf-8\")\n+\n+    (default_home / \".env\").write_text(\n+        \"TELEGRAM_BOT_TOKEN=[redacted:secret-assignment]\", encoding=\"utf-8\"\n+    )\n+    (worker_home / \".env\").write_text(\"\", encoding=\"utf-8\")\n+\n+    monkeypatch.setattr(profiles, \"_get_default_hermes_home\", lambda: default_home)\n+    monkeypatch.setattr(profiles, \"_get_profiles_root\", lambda: profiles_root)\n+    return {\"default\": default_home, \"worker_alpha\": worker_home}\n+\n+\n[redacted:email]\n+def client(monkeypatch, isolated_profiles):\n+    try:\n+        from starlette.testclient import TestClient\n+    except ImportError:\n+        pytest.skip(\"fastapi/starlette not installed\")\n+\n+    import hermes_state\n+    from hermes_constants import get_hermes_home\n+    from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN\n+\n+    monkeypatch.setattr(hermes_state, \"DEFAULT_DB_PATH\", get_hermes_home() / \"state.db\")\n+    # The dashboard process's os.environ may carry root-install credentials;\n+    # make sure the scoped path never falls back to them.\n+    monkeypatch.delenv(\"TELEGRAM_BOT_TOKEN\", raising=False)\n+    c = TestClient(app)\n+    c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN\n+    return c\n+\n+\n+def _telegram(payload):\n+    return next(p for p in payload[\"platforms\"] if p[\"id\"] == \"telegram\")\n+\n+\n+def _env_field(platform, key):\n+    return next(f for f in platform[\"env_vars\"] if f[\"key\"] == key)\n+\n+\n+class TestProfileScopedMessagingReads:\n+    def test_scoped_read_does_not_show_root_credentials(\n+        self, client, isolated_profiles\n+    ):\n+        resp = client.get(\n+            \"/api/messaging/platforms\", params={\"profile\": \"worker_alpha\"}\n+        )\n+        assert resp.status_code == 200\n+        telegram = _telegram(resp.json())\n+        token = [redacted:secret-assignment] \"TELEGRAM_BOT_TOKEN\")\n+        # The worker profile has an empty .env — the root token must not leak.\n+        assert token[\"is_set\"] is False\n+        assert telegram[\"configured\"] is False\n+\n+    def test_unscoped_read_shows_dashboard_profile_env(\n+        self, client, isolated_profiles\n+    ):\n+        resp = client.get(\"/api/messaging/platforms\")\n+        assert resp.status_code == 200\n+        telegram = _telegram(resp.json())\n+        token = [redacted:secret-assignment] \"TELEGRAM_BOT_TOKEN\")\n+        assert token[\"is_set\"] is True\n+\n+    def test_unknown_profile_returns_404(self, client, isolated_profiles):\n+        resp = client.get(\n+            \"/api/messaging/platforms\", params={\"profile\": \"no_such_profile\"}\n+        )\n+        assert resp.status_code == 404\n+\n+\n+class TestProfileScopedMessagingWrites:\n+    def test_scoped_write_lands_in_target_profile_env(\n+        self, client, isolated_profiles\n+    ):\n+        resp = client.put(\n+            \"/api/messaging/platforms/telegram\",\n+            params={\"profile\": \"worker_alpha\"},\n+            json={\n+                \"enabled\": True,\n+                \"env\": {\"TELEGRAM_BOT_TOKEN\": \"worker-token\"},\n+            },\n+        )\n+        assert resp.status_code == 200\n+\n+        worker_env = (\n+            isolated_profiles[\"worker_alpha\"] / \".env\"\n+        ).read_text(encoding=\"utf-8\")\n+        assert \"TELEGRAM_BOT_TOKEN=[redacted:secret-assignment]\" in worker_env\n+\n+        # The dashboard's own .env must stay untouched — this was the bug.\n+        root_env = (isolated_profiles[\"default\"] / \".env\").read_text(\n+            encoding=\"utf-8\"\n+        )\n+        assert \"worker-token\" not in root_env\n+        assert \"TELEGRAM_BOT_TOKEN=[redacted:secret-assignment]\" in root_env\n+\n+        # Enablement lands in the target profile's config.yaml.\n+        worker_cfg = yaml.safe_load(\n+            (isolated_profiles[\"worker_alpha\"] / \"config.yaml\").read_text()\n+        ) or {}\n+        assert worker_cfg.get(\"platforms\", {}).get(\"telegram\", {}).get(\"enabled\") is True\n+        root_cfg = yaml.safe_load(\n+            (isolated_profiles[\"default\"] / \"config.yaml\").read_text()\n+        ) or {}\n+        assert \"telegram\" not in (root_cfg.get(\"platforms\") or {})\n+\n+    def test_body_profile_beats_query_param(self, client, isolated_profiles):\n+        resp = client.put(\n+            \"/api/messaging/platforms/telegram\",\n+            json={\n+                \"env\": {\"TELEGRAM_BOT_TOKEN\": \"body-token\"},\n+                \"profile\": \"worker_alpha\",\n+            },\n+        )\n+        assert resp.status_code == 200\n+        worker_env = (\n+            isolated_profiles[\"worker_alpha\"] / \".env\"\n+        ).read_text(encoding=\"utf-8\")\n+        assert \"TELEGRAM_BOT_TOKEN=[redacted:secret-assignment]\" in worker_env\n+\n+    def test_scoped_read_after_scoped_write_round_trips(\n+        self, client, isolated_profiles\n+    ):\n+        client.put(\n+            \"/api/messaging/platforms/telegram\",\n+            params={\"profile\": \"worker_alpha\"},\n+            json={\"enabled\": True, \"env\": {\"TELEGRAM_BOT_TOKEN\": \"worker-token\"}},\n+        )\n+        resp = client.get(\n+            \"/api/messaging/platforms\", params={\"profile\": \"worker_alpha\"}\n+        )\n+        telegram = _telegram(resp.json())\n+        assert telegram[\"enabled\"] is True\n+        assert _env_field(telegram, \"TELEGRAM_BOT_TOKEN\")[\"is_set\"] is True\n+        assert telegram[\"configured\"] is True\n+\n+    def test_scoped_clear_env_removes_from_target_only(\n+        self, client, isolated_profiles\n+    ):\n+        client.put(\n+            \"/api/messaging/platforms/telegram\",\n+            params={\"profile\": \"worker_alpha\"},\n+            json={\"env\": {\"TELEGRAM_BOT_TOKEN\": \"worker-token\"}},\n+        )\n+        resp = client.put(\n+            \"/api/messaging/platforms/telegram\",\n+            params={\"profile\": \"worker_alpha\"},\n+            json={\"clear_env\": [\"TELEGRAM_BOT_TOKEN\"]},\n+        )\n+        assert resp.status_code == 200\n+        worker_env = (\n+            isolated_profiles[\"worker_alpha\"] / \".env\"\n+        ).read_text(encoding=\"utf-8\")\n+        assert \"worker-token\" not in worker_env\n+        root_env = (isolated_profiles[\"default\"] / \".env\").read_text(\n+            encoding=\"utf-8\"\n+        )\n+        assert \"TELEGRAM_BOT_TOKEN=[redacted:secret-assignment]\" in root_env"]},{"file":"web/src/lib/api.ts","hunks":["@@ -60,14 +60,16 @@ export function getManagementProfile(): string {\n \n // Endpoint families that honor ?profile= on the backend (web_server.py\n // _profile_scope). Anything else — sessions, analytics, ops, pairing,\n-// channels, cron (which has its own per-job profile params), profiles\n-// themselves — is machine-global or self-scoped and must NOT be rewritten.\n+// telegram onboarding, cron (which has its own per-job profile params),\n+// profiles themselves — is machine-global or self-scoped and must NOT be\n+// rewritten.\n const PROFILE_SCOPED_PREFIXES = [\n   \"/api/skills\",\n   \"/api/tools/toolsets\",\n   \"/api/config\",\n   \"/api/env\",\n   \"/api/mcp\",\n+  \"/api/messaging/platforms\",\n   \"/api/model/info\",\n   \"/api/model/set\",\n   \"/api/model/auxiliary\",\n"]}]},{"kind":"pr","number":66424,"title":"fix: fallback to active profile name when profile not provided in model set (#66406)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"875aa8f162aa40f07b19b2ca229720da70193d41","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}