# NousResearch/hermes-agent · Dashboard multi-profile scoping and config writes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 6 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #44792. Verify it addresses your case.
- **Recommended:** read PR #44792 (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: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.25`: 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

- #26066: \[Feature\]: Dashboard Sessions/Config/Keys tabs need a \`?profile=\` scope selector so one dashboard process can view & edit per-profile state
- #44193: Multi-profile dashboard: config/env/platform writes always target the launch profile
- #66406: \[Bug\]: Dashboard model change with --open-profile writes to default profile config (HERMES_HOME not propagated, #18594 family)

## Prior & competing PRs

- **#44792: fix(dashboard): profile-scope Channels endpoints and seed per-profile .env**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `hermes_cli/profiles.py`

    ```diff
    @@ -835,6 +835,25 @@ def create_profile(
                         dst.parent.mkdir(parents=True, exist_ok=True)
                         shutil.copy2(src, dst)
     
    +    # Seed an empty .env so the profile has its own credentials file from
    +    # day one. Without it, profile-scoped env writes (dashboard Channels /
    +    # Keys pages, `hermes -p <name> auth add`) had no file until first
    +    # write, and the profile silently inherited API keys from the shell
    +    # environment — users reasonably read that as "the new profile reads
    +    # the root .env". Skipped when --clone/--clone-all already copied one.
    +    env_path = profile_dir / ".env"
    +    if not env_path.exists():
    +        try:
    +            env_path.write_text(
    +                "# Per-profile secrets for this Hermes profile.\n"
    +                "# API keys and tokens set here override the shell environment.\n"
    +                "# Behavioral settings belong in config.yaml, not here.\n",
    +                encoding="utf-8",
    +            )
    +            os.chmod(str(env_path), 0o600)
    +        except OSError:
    +            pass  # best-effort — save_env_value creates the file on demand
    +
         # Seed a default SOUL.md so the user has a file to customize immediately.
         # Skipped when the profile already has one (from --clone / --clone-all).
         soul_path = profile_dir / "SOUL.md"
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -654,6 +654,9 @@ class MessagingPlatformUpdate(BaseModel):
         enabled: Optional[bool] = None
         env: Dict[str, str] = {}
         clear_env: List[str] = []
    +    # Explicit body profile beats the query param injected by the global
    +    # dashboard profile switcher (same precedence as other scoped writes).
    +    profile: Optional[str] = None
     
     
     class TelegramOnboardingStart(BaseModel):
    ```

    ```diff
    @@ -4209,7 +4212,10 @@ def _gateway_platform_config(platform_id: str):
     
     
     def _messaging_platform_payload(
    -    entry: dict[str, Any], env_on_disk: dict[str, str], runtime: dict | None
    +    entry: dict[str, Any],
    +    env_on_disk: dict[str, str],
    +    runtime: dict | None,
    +    scoped: bool = False,
     ) -> dict[str, Any]:
         platform_id = entry["id"]
         gateway_running = get_running_pid() is not None
    ```

    ```diff
    @@ -4222,7 +4228,11 @@ def _messaging_platform_payload(
         env_vars = []
     
         for key in entry["env_vars"]:
    -        value = env_on_disk.get(key) or os.getenv(key, "")
    +        # When profile-scoped, judge only the profile's own .env — the
    +        # dashboard process's os.environ carries the ROOT install's .env
    +        # (loaded at startup) and would falsely report the root credentials
    +        # as the profile's.
    +        value = env_on_disk.get(key) or ("" if scoped else os.getenv(key, ""))
             env_vars.append(
                 {
                     "key": key,
    ```

    ```diff
    @@ -4233,26 +4243,46 @@ def _messaging_platform_payload(
                 }
             )
     
    -    try:
    -        gateway_config, platform, platform_config = _gateway_platform_config(
    -            platform_id
    -        )
    -        enabled = bool(platform_config and platform_config.enabled)
    -        configured = bool(
    -            platform_config
    -            and gateway_config._is_platform_connected(platform, platform_config)
    -        )
    -        home_channel = (
    -            platform_config.home_channel.to_dict()
    -            if platform_config and platform_config.home_channel
    -            else None
    -        )
    -    except Exception:
    -        enabled = False
    -        configured = all(
    -            env_on_disk.get(key) or os.getenv(key, "") for key in entry["required_env"]
    -        )
    -        home_channel = None
    +    if scoped:
    +        # Profile-scoped view: derive enablement/configuration from the
    +        # profile's config.yaml + .env only. load_gateway_config()'s
    +        # env-override layer reads os.environ and would leak the root
    +        # install's tokens into the profile's reported state.
    +        try:
    +            cfg = load_config()
    +            platforms_cfg = cfg.get("platforms") or {}
    +            plat_cfg = platforms_cfg.get(platform_id)
    +            if not isinstance(plat_cfg, dict):
    +                plat_cfg = {}
    +            enabled = bool(plat_cfg.get("enabled"))
    +            hc = plat_cfg.get("home_channel")
    +            home_channel = hc if isinstance(hc, dict) else None
    +        except Exception:
    +            enabled = False
    +            home_channel = None
    +        configured = all(env_on_disk.get(key) for key in entry["required_env"])
    +    else:
    +        try:
    +            gateway_config, platform, platform_config = _gateway_platform_config(
    +                platform_id
    +            )
    +            enabled = bool(platform_config and platform_config.enabled)
    +            configured = bool(
    +                platform_config
    +                and gateway_config._is_platform_connected(platform, platform_config)
    +            )
    +            home_channel = (
    +                platform_config.home_channel.to_dict()
    +                if platform_config and platform_config.home_channel
    +                else None
    +            )
    +        except Exception:
    +            enabled = False
    +            configured = all(
    +                env_on_disk.get(key) or os.getenv(key, "")
    +                for key in entry["required_env"]
    +            )
    +            home_channel = None
     
         state = (
             runtime_platform.get("state") if isinstance(runtime_platform, dict) else None
    ```

    ```diff
    @@ -4675,19 +4705,28 @@ async def cancel_telegram_onboarding(pairing_id: str):
     
     
     @app.get("/api/messaging/platforms")
    -async def get_messaging_platforms():
    -    env_on_disk = load_env()
    -    runtime = read_runtime_status()
    -    return {
    -        "platforms": [
    -            _messaging_platform_payload(entry, env_on_disk, runtime)
    -            for entry in _messaging_platform_catalog()
    -        ]
    -    }
    +async def get_messaging_platforms(profile: Optional[str] = None):
    +    # Profile-scoped so the dashboard's global profile switcher shows the
    +    # TARGET profile's channel credentials/state, not the root install's.
    +    # Inside _profile_scope, load_env()/read_runtime_status()/get_running_pid()
    +    # all resolve against the requested profile's HERMES_HOME.
    +    with _profile_scope(profile) as scoped_dir:
    +        env_on_disk = load_env()
    +        runtime = read_runtime_status()
    +        return {
    +            "platforms": [
    +                _messaging_platform_payload(
    +                    entry, env_on_disk, runtime, scoped=scoped_dir is not None
    +                )
    +                for entry in _messaging_platform_catalog()
    +            ]
    +        }
     
     
     @app.put("/api/messaging/platforms/{platform_id}")
    -async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpdate):
    +async def update_messaging_platform(
    +    platform_id: str, body: MessagingPlatformUpdate, profile: Optional[str] = None
    +):
         entry = _catalog_lookup(platform_id)
         if not entry:
             raise HTTPException(
    ```

    ```diff
    @@ -4696,26 +4735,27 @@ async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpd
     
         allowed_env = set(entry["env_vars"])
         try:
    -        for key in body.clear_env:
    -            if key not in allowed_env:
    -                raise HTTPException(
    -                    status_code=400,
    -                    detail=f"{key} is not configurable for {entry['name']}",
    -                )
    -            remove_env_value(key)
    +        with _profile_scope(body.profile or profile):
    +            for key in body.clear_env:
    +                if key not in allowed_env:
    +                    raise HTTPException(
    +                        status_code=400,
    +                        detail=f"{key} is not configurable for {entry['name']}",
    +                    )
    +                remove_env_value(key)
     
    -        for key, value in body.env.items():
    -            if key not in allowed_env:
    -                raise HTTPException(
    -                    status_code=400,
    -                    detail=f"{key} is not configurable for {entry['name']}",
    -                )
    -            trimmed = value.strip()
    -            if trimmed:
    -                save_env_value(key, trimmed)
    +            for key, value in body.env.items():
    +                if key not in allowed_env:
    +                    raise HTTPException(
    +                        status_code=400,
    +                        detail=f"{key} is not configurable for {entry['name']}",
    +                    )
    +                trimmed = value.strip()
    +                if trimmed:
    +                    save_env_value(key, trimmed)
     
    -        if body.enabled is not None:
    -            _write_platform_enabled(platform_id, body.enabled)
    +            if body.enabled is not None:
    +                _write_platform_enabled(platform_id, body.enabled)
     
             return {"ok": True, "platform": platform_id}
         except HTTPException:
    ```

    ```diff
    @@ -4726,15 +4766,18 @@ async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpd
     
     
     @app.post("/api/messaging/platforms/{platform_id}/test")
    -async def test_messaging_platform(platform_id: str):
    +async def test_messaging_platform(platform_id: str, profile: Optional[str] = None):
         entry = _catalog_lookup(platform_id)
         if not entry:
             raise HTTPException(
                 status_code=404, detail=f"Unknown messaging platform: {platform_id}"
             )
     
    -    env_on_disk = load_env()
    -    payload = _messaging_platform_payload(entry, env_on_disk, read_runtime_status())
    +    with _profile_scope(profile) as scoped_dir:
    +        env_on_disk = load_env()
    +        payload = _messaging_platform_payload(
    +            entry, env_on_disk, read_runtime_status(), scoped=scoped_dir is not None
    +        )
         if not payload["enabled"]:
             message = f"{entry['name']} is disabled. Enable it, then restart the gateway."
             return {"ok": False, "state": payload["state"], "message": message}
    ```
  - `tests/hermes_cli/test_profiles.py`

    ```diff
    @@ -158,6 +158,30 @@ def test_creates_directory_with_subdirs(self, profile_env):
                             "plans", "workspace", "cron"]:
                 assert (profile_dir / subdir).is_dir(), f"Missing subdir: {subdir}"
     
    +    def test_seeds_placeholder_env_file(self, profile_env):
    +        """Fresh profiles get their own .env (owner-only) so channel/env
    +        writes are profile-scoped from day one instead of falling through
    +        to the shell environment / root install."""
    +        import stat
    +        profile_dir = create_profile("coder", no_alias=True)
    +        env_path = profile_dir / ".env"
    +        assert env_path.exists()
    +        content = env_path.read_text(encoding="utf-8")
    +        # Placeholder only — no credentials leak in from anywhere.
    +        assert all(
    +            line.startswith("#") or not line.strip()
    +            for line in content.splitlines()
    +        )
    +        mode = stat.S_IMODE(env_path.stat().st_mode)
    +        assert mode == 0o600
    +
    +    def test_seeded_env_does_not_clobber_cloned_env(self, profile_env):
    +        tmp_path = profile_env
    +        default_home = tmp_path / ".hermes"
    +        (default_home / ".env").write_text("KEY=val")
    +        profile_dir = create_profile("coder", clone_config=True, no_alias=True)
    +        assert (profile_dir / ".env").read_text() == "KEY=val"
    +
         def test_duplicate_raises_file_exists(self, profile_env):
             create_profile("coder", no_alias=True)
             with pytest.raises(FileExistsError):
    ```

    ```diff
    @@ -304,7 +328,9 @@ def test_clone_config_missing_files_skipped(self, profile_env):
             profile_dir = create_profile("coder", clone_config=True, no_alias=True)
             # No error; optional files just not copied
             assert not (profile_dir / "config.yaml").exists()
    -        assert not (profile_dir / ".env").exists()
    +        # .env is always seeded (placeholder) so the profile has its own
    +        # credentials file even when the clone source lacked one.
    +        assert (profile_dir / ".env").exists()
             # SOUL.md is always seeded with the default even when clone source lacks it
             assert (profile_dir / "SOUL.md").exists()
     
    ```
  - `tests/hermes_cli/test_web_server_messaging_profiles.py`

    ```diff
    @@ -0,0 +1,182 @@
    +"""Regression tests for profile-scoped dashboard Channels endpoints.
    +
    +Before the ``profile`` parameter existed, ``/api/messaging/platforms`` always
    +read/wrote the dashboard process's own (root) ``.env`` via ``load_env()`` /
    +``save_env_value()`` — so a dashboard switched to a freshly created profile
    +still displayed and persisted the ROOT install's messaging credentials.
    +These tests pin the new behavior: reads and writes land in the REQUESTED
    +profile's HERMES_HOME, and the dashboard's own profile stays untouched.
    +"""
    +import pytest
    +import yaml
    +
    +
    [redacted:email]
    +def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
    +    """Isolated default home + one named profile, each with its own .env."""
    +    from hermes_constants import get_hermes_home
    +    from hermes_cli import profiles
    +
    +    default_home = get_hermes_home()
    +    profiles_root = default_home / "profiles"
    +    worker_home = profiles_root / "worker_alpha"
    +    for home in (default_home, worker_home):
    +        home.mkdir(parents=True, exist_ok=True)
    +        (home / "config.yaml").write_text("{}\n", encoding="utf-8")
    +
    +    (default_home / ".env").write_text(
    +        "TELEGRAM_BOT_TOKEN=[redacted:secret-assignment]", encoding="utf-8"
    +    )
    +    (worker_home / ".env").write_text("", encoding="utf-8")
    +
    +    monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
    +    monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
    +    return {"default": default_home, "worker_alpha": worker_home}
    +
    +
    [redacted:email]
    +def client(monkeypatch, isolated_profiles):
    +    try:
    +        from starlette.testclient import TestClient
    +    except ImportError:
    +        pytest.skip("fastapi/starlette not installed")
    +
    +    import hermes_state
    +    from hermes_constants import get_hermes_home
    +    from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
    +
    +    monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
    +    # The dashboard process's os.environ may carry root-install credentials;
    +    # make sure the scoped path never falls back to them.
    +    monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
    +    c = TestClient(app)
    +    c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
    +    return c
    +
    +
    +def _telegram(payload):
    +    return next(p for p in payload["platforms"] if p["id"] == "telegram")
    +
    +
    +def _env_field(platform, key):
    +    return next(f for f in platform["env_vars"] if f["key"] == key)
    +
    +
    +class TestProfileScopedMessagingReads:
    +    def test_scoped_read_does_not_show_root_credentials(
    +        self, client, isolated_profiles
    +    ):
    +        resp = client.get(
    +            "/api/messaging/platforms", params={"profile": "worker_alpha"}
    +        )
    +        assert resp.status_code == 200
    +        telegram = _telegram(resp.json())
    +        token = [redacted:secret-assignment] "TELEGRAM_BOT_TOKEN")
    +        # The worker profile has an empty .env — the root token must not leak.
    +        assert token["is_set"] is False
    +        assert telegram["configured"] is False
    +
    +    def test_unscoped_read_shows_dashboard_profile_env(
    +        self, client, isolated_profiles
    +    ):
    +        resp = client.get("/api/messaging/platforms")
    +        assert resp.status_code == 200
    +        telegram = _telegram(resp.json())
    +        token = [redacted:secret-assignment] "TELEGRAM_BOT_TOKEN")
    +        assert token["is_set"] is True
    +
    +    def test_unknown_profile_returns_404(self, client, isolated_profiles):
    +        resp = client.get(
    +            "/api/messaging/platforms", params={"profile": "no_such_profile"}
    +        )
    +        assert resp.status_code == 404
    +
    +
    +class TestProfileScopedMessagingWrites:
    +    def test_scoped_write_lands_in_target_profile_env(
    +        self, client, isolated_profiles
    +    ):
    +        resp = client.put(
    +            "/api/messaging/platforms/telegram",
    +            params={"profile": "worker_alpha"},
    +            json={
    +                "enabled": True,
    +                "env": {"TELEGRAM_BOT_TOKEN": "worker-token"},
    +            },
    +        )
    +        assert resp.status_code == 200
    +
    +        worker_env = (
    +            isolated_profiles["worker_alpha"] / ".env"
    +        ).read_text(encoding="utf-8")
    +        assert "TELEGRAM_BOT_TOKEN=[redacted:secret-assignment]" in worker_env
    +
    +        # The dashboard's own .env must stay untouched — this was the bug.
    +        root_env = (isolated_profiles["default"] / ".env").read_text(
    +            encoding="utf-8"
    +        )
    +        assert "worker-token" not in root_env
    +        assert "TELEGRAM_BOT_TOKEN=[redacted:secret-assignment]" in root_env
    +
    +        # Enablement lands in the target profile's config.yaml.
    +        worker_cfg = yaml.safe_load(
    +            (isolated_profiles["worker_alpha"] / "config.yaml").read_text()
    +        ) or {}
    +        assert worker_cfg.get("platforms", {}).get("telegram", {}).get("enabled") is True
    +        root_cfg = yaml.safe_load(
    +            (isolated_profiles["default"] / "config.yaml").read_text()
    +        ) or {}
    +        assert "telegram" not in (root_cfg.get("platforms") or {})
    +
    +    def test_body_profile_beats_query_param(self, client, isolated_profiles):
    +        resp = client.put(
    +            "/api/messaging/platforms/telegram",
    +            json={
    +                "env": {"TELEGRAM_BOT_TOKEN": "body-token"},
    +                "profile": "worker_alpha",
    +            },
    +        )
    +        assert resp.status_code == 200
    +        worker_env = (
    +            isolated_profiles["worker_alpha"] / ".env"
    +        ).read_text(encoding="utf-8")
    +        assert "TELEGRAM_BOT_TOKEN=[redacted:secret-assignment]" in worker_env
    +
    +    def test_scoped_read_after_scoped_write_round_trips(
    +        self, client, isolated_profiles
    +    ):
    +        client.put(
    +            "/api/messaging/platforms/telegram",
    +            params={"profile": "worker_alpha"},
    +            json={"enabled": True, "env": {"TELEGRAM_BOT_TOKEN": "worker-token"}},
    +        )
    +        resp = client.get(
    +            "/api/messaging/platforms", params={"profile": "worker_alpha"}
    +        )
    +        telegram = _telegram(resp.json())
    +        assert telegram["enabled"] is True
    +        assert _env_field(telegram, "TELEGRAM_BOT_TOKEN")["is_set"] is True
    +        assert telegram["configured"] is True
    +
    +    def test_scoped_clear_env_removes_from_target_only(
    +        self, client, isolated_profiles
    +    ):
    +        client.put(
    +            "/api/messaging/platforms/telegram",
    +            params={"profile": "worker_alpha"},
    +            json={"env": {"TELEGRAM_BOT_TOKEN": "worker-token"}},
    +        )
    +        resp = client.put(
    +            "/api/messaging/platforms/telegram",
    +            params={"profile": "worker_alpha"},
    +            json={"clear_env": ["TELEGRAM_BOT_TOKEN"]},
    +        )
    +        assert resp.status_code == 200
    +        worker_env = (
    +            isolated_profiles["worker_alpha"] / ".env"
    +        ).read_text(encoding="utf-8")
    +        assert "worker-token" not in worker_env
    +        root_env = (isolated_profiles["default"] / ".env").read_text(
    +            encoding="utf-8"
    +        )
    +        assert "TELEGRAM_BOT_TOKEN=[redacted:secret-assignment]" in root_env
    ```
  - `web/src/lib/api.ts`

    ```diff
    @@ -60,14 +60,16 @@ export function getManagementProfile(): string {
     
     // Endpoint families that honor ?profile= on the backend (web_server.py
     // _profile_scope). Anything else — sessions, analytics, ops, pairing,
    -// channels, cron (which has its own per-job profile params), profiles
    -// themselves — is machine-global or self-scoped and must NOT be rewritten.
    +// telegram onboarding, cron (which has its own per-job profile params),
    +// profiles themselves — is machine-global or self-scoped and must NOT be
    +// rewritten.
     const PROFILE_SCOPED_PREFIXES = [
       "/api/skills",
       "/api/tools/toolsets",
       "/api/config",
       "/api/env",
       "/api/mcp",
    +  "/api/messaging/platforms",
       "/api/model/info",
       "/api/model/set",
       "/api/model/auxiliary",
    ```
- **#44409: fix(dashboard): ensure messaging platform writes target active profile**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `88dbf95105644efb067500136c4a73277d3936d4`, `#44792`
- **#66424: fix: fallback to active profile name when profile not provided in model set (#66406)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `875aa8f162aa40f07b19b2ca229720da70193d41`

## Fix status

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