{"repo":"NousResearch/hermes-agent","public_id":"57547","display_label":"Custom endpoint API keys stored in plaintext config.yaml","summary":"Most members concern custom endpoint API keys being written to config.yaml instead of .env or a key_env reference, including stale keys persisting after updates. A few also touch related credential handling, such as deleting or updating keys across files and keeping the full model list on Save.","subsystem":"agent","counts":{"members":11,"open_issues":1,"open_issues_distinct":1,"open_prs":2,"closed_issues":2,"merged_prs":2,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":71141,"merged_at":"2026-07-25T03:09:53+00:00"},"resolution":{"state":"partially_resolved","by":"pr-67213,pr-71141"},"freshness":{"state":"verified","last_verified_at":"2026-07-25T03:09:53+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1949090909090909},"fix_edges":[{"issue_number":40041,"pr_number":67213,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-19T10:02:22+00:00"},{"issue_number":51071,"pr_number":67213,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-19T10:02:22+00:00"},{"issue_number":57547,"pr_number":71141,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-25T03:09:53+00:00"},{"issue_number":59761,"pr_number":67213,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-19T10:02:22+00:00"},{"issue_number":62269,"pr_number":67213,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-19T10:02:22+00:00"},{"issue_number":69449,"pr_number":71141,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-25T03:09:53+00:00"},{"issue_number":69988,"pr_number":71141,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-25T03:09:53+00:00"}],"members":[{"kind":"issue","number":57547,"title":"[Bug]: \"Custom endpoint\" model provider places API key inside config.yaml, easily leaking into context when Hermes updates its own config.","url":null,"state":"open"},{"kind":"issue","number":62269,"title":"[Bug]: \"API key updated in UI but old key persists in config.yaml, causing authentication failures\"","url":null,"state":"closed"},{"kind":"issue","number":69449,"title":"[Security] Custom endpoint API key stored in plaintext in config.yaml","url":null,"state":"closed"},{"kind":"pr","number":57557,"title":"fix(cli): store custom endpoint API key in .env instead of config.yaml","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#71141","number":71141}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":62416,"title":"fix(web): retemplate stale env-backed api keys","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"26f39f7b900185f4953cdaac5819ca831155fb08","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":62900,"title":"fix(config): clear stale model.api_key from config.yaml when env var is updated","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":67213,"title":"fix(credentials): unified provider key delete/update across .env, auth.json, config.yaml","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","hermes_cli/credential_lifecycle.py","hermes_cli/web_server.py","tests/hermes_cli/test_credential_lifecycle.py","tests/hermes_cli/test_env_export_line_lifecycle.py","tui_gateway/server.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -7846,6 +7846,20 @@ def _quote_env_value(value: str) -> str:\n     return f'\"{escaped}\"'\n \n \n+def _env_line_defines_key(line: str, key: str) -> bool:\n+    \"\"\"True when a .env line assigns ``key`` — plain or ``export``-prefixed.\n+\n+    ``load_env()`` accepts the bash-compatible ``export KEY=value`` form\n+    (#6659), so the writers must recognise the same shape. Otherwise a\n+    hand-added ``export`` line is invisible to save (duplicate appended) and\n+    remove (line survives → the value resurrects on the next load, #40041).\n+    \"\"\"\n+    stripped = line.strip()\n+    if stripped.startswith(\"export \"):\n+        stripped = stripped[7:].lstrip()\n+    return stripped.startswith(f\"{key}=\")\n+\n+\n def save_env_value(key: str, value: str):\n     \"\"\"Save or update a value in ~/.hermes/.env.\"\"\"\n     if is_managed():","@@ -7887,10 +7901,15 @@ def save_env_value(key: str, value: str):\n \n     serialized_value = _quote_env_value(value)\n \n-    # Find and update or append\n+    # Find and update or append. Match both ``KEY=`` and the bash-compatible\n+    # ``export KEY=`` form — load_env() parses export lines (#6659), so a\n+    # user-added ``export GITHUB_TOKEN=...`` shows as set in every UI. If the\n+    # writer didn't match it, a save would append a SECOND line and a later\n+    # delete of that line would silently resurrect the old exported value\n+    # (#40041: \"token detected but cannot be replaced through the UI\").\n     found = False\n     for i, line in enumerate(lines):\n-        if line.strip().startswith(f\"{key}=\"):\n+        if _env_line_defines_key(line, key):\n             lines[i] = f\"{key}={serialized_value}\\n\"\n             found = True\n             break","@@ -7969,7 +7988,7 @@ def remove_env_value(key: str) -> bool:\n         lines = f.readlines()\n     lines = _sanitize_env_lines(lines)\n \n-    new_lines = [line for line in lines if not line.strip().startswith(f\"{key}=\")]\n+    new_lines = [line for line in lines if not _env_line_defines_key(line, key)]\n     found = len(new_lines) < len(lines)\n \n     if found:","@@ -8030,7 +8049,12 @@ def save_anthropic_api_key(value: str, save_fn=None):\n \n \n def save_env_value_secure(key: str, value: str) -> Dict[str, Any]:\n-    save_env_value(key, value)\n+    # Route through the unified credential lifecycle so a rotation via the\n+    # secret-capture path also refreshes any config.yaml mirror of the old\n+    # value and lifts a prior env-source suppression (#62269 fix family).\n+    from hermes_cli.credential_lifecycle import save_provider_env_credential\n+\n+    save_provider_env_credential(key, value)\n     return {\n         \"success\": True,\n         \"stored_as\": key,","@@ -8450,7 +8474,11 @@ def set_config_value(key: str, value: str):\n         sys.exit(1)\n     # Check if it's an API key (goes to .env)\n     if _is_env_config_key(key):\n-        save_env_value(key.upper(), value)\n+        # Unified lifecycle: also rotates any config.yaml mirror of the old\n+        # value so a stale higher-precedence copy can't win (#62269).\n+        from hermes_cli.credential_lifecycle import save_provider_env_credential\n+\n+        save_provider_env_credential(key.upper(), value)\n         print(f\"✓ Set {key} in {get_env_path()}\")\n         return\n     ","@@ -8556,8 +8584,12 @@ def unset_config_value(key: str):\n         sys.exit(1)\n \n     if _is_env_config_key(key):\n-        removed = remove_env_value(key.upper())\n-        if not removed:\n+        # Unified lifecycle: prune env-seeded credential_pool entries and\n+        # model-cache rows too, so `hermes config unset <KEY>` fully removes\n+        # the provider instead of leaving it resurrectable (#51071 family).\n+        from hermes_cli.credential_lifecycle import remove_provider_env_credential\n+\n+        if not remove_provider_env_credential(key.upper()).get(\"found\"):\n             print(f\"Config key not set: {key}\", file=sys.stderr)\n             sys.exit(1)\n         print(f\"✓ Unset {key} from {get_env_path()}\")"]},{"file":"hermes_cli/credential_lifecycle.py","hunks":["@@ -0,0 +1,272 @@\n+\"\"\"Unified provider-credential lifecycle across every store Hermes reads.\n+\n+A provider API key can live in up to THREE stores at once:\n+\n+    1. ``~/.hermes/.env``                     — the canonical secret store\n+    2. ``~/.hermes/auth.json`` →\n+       ``credential_pool.<provider>[*]``      — env-seeded pool entries\n+       (``source == \"env:<VAR>\"``) persisted by the pool loader\n+    3. ``~/.hermes/config.yaml``              — inline mirrors written by the\n+       custom-endpoint flows (``model.api_key``, ``auxiliary.<task>.api_key``,\n+       ``custom_providers[*].api_key``)\n+\n+Historically the desktop/dashboard endpoints (PUT/DELETE ``/api/env``) and the\n+TUI-gateway RPCs only mutated store 1. That divergence is the root cause of a\n+whole bug family:\n+\n+    * #51071 / #59761 — deleting a key removes it from ``.env`` but the stale\n+      ``credential_pool`` entry (and ``provider_models_cache.json`` row)\n+      survives, so the provider keeps appearing in the model picker, even\n+      across restarts (the pool loader is additive-only).\n+    * #62269 — updating a key rewrites ``.env`` but leaves the OLD key in a\n+      higher-precedence ``config.yaml`` mirror (``model.api_key`` wins over\n+      env at client construction), producing persistent 401s with a key the\n+      UI no longer shows.\n+\n+This module is the single choke point: every surface that saves or removes a\n+provider credential should route through :func:`save_provider_env_credential`\n+/ :func:`remove_provider_env_credential` so all three stores stay consistent.\n+\n+OAuth preservation contract: removal only prunes credential-pool entries whose\n+``source`` is exactly ``env:<VAR>``. OAuth/device-code/manual/borrowed entries\n+(``device_code``, ``manual*``, ``gh_cli``, ``claude_code``, ``oauth``, …) and\n+the ``providers.<id>`` OAuth token blocks in auth.json are never touched —\n+deleting an API key must not revoke an OAuth grant for the same provider.\n+\n+Secrecy contract: no function in this module logs, prints, or returns a\n+credential value. Results carry key NAMES and config PATHS only.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from typing import Any, Dict, List\n+\n+__all__ = [\n+    \"save_provider_env_credential\",\n+    \"remove_provider_env_credential\",\n+    \"purge_env_credential_references\",\n+]\n+\n+\n+def _providers_for_env_var(env_var: str) -> List[str]:\n+    \"\"\"Provider ids whose registered api_key_env_vars include ``env_var``.\"\"\"\n+    try:\n+        from hermes_cli.auth import PROVIDER_REGISTRY\n+    except Exception:\n+        return []\n+    hits: List[str] = []\n+    for pid, cfg in PROVIDER_REGISTRY.items():\n+        try:\n+            if env_var in (cfg.api_key_env_vars or ()):\n+                hits.append(pid)\n+        except Exception:\n+            continue\n+    return hits\n+\n+\n+def _prune_env_pool_entries(env_var: str) -> List[str]:\n+    \"\"\"Drop ``credential_pool`` entries seeded from ``env:<env_var>``.\n+\n+    Operates across ALL providers in the pool (the source string names the\n+    env var unambiguously, and shared vars like GITHUB_TOKEN may seed more\n+    than one provider). Entries with any other source — OAuth, device-code,\n+    manual, borrowed-CLI — are preserved verbatim, as are the\n+    ``providers.<id>`` OAuth blocks.\n+\n+    Returns the list of provider ids that had entries pruned.\n+    \"\"\"\n+    from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store\n+\n+    source = f\"env:{env_var}\"\n+    pruned: List[str] = []\n+    with _auth_store_lock():\n+        auth_store = _load_auth_store()\n+        pool = auth_store.get(\"credential_pool\")\n+        if not isinstance(pool, dict):\n+            return pruned\n+        changed = False\n+        for provider in list(pool.keys()):\n+            entries = pool[provider]\n+            if not isinstance(entries, list):\n+                continue\n+            kept = [\n+                entry\n+                for entry in entries\n+                if not (isinstance(entry, dict) and entry.get(\"source\") == source)\n+            ]\n+            if len(kept) == len(entries):\n+                continue\n+            changed = True\n+            pruned.append(provider)\n+            if kept:\n+                pool[provider] = kept\n+            else:\n+                del pool[provider]\n+        if changed:\n+            _save_auth_store(auth_store)\n+    return pruned\n+\n+\n+def _scrub_config_yaml_mirrors(old_value: str, new_value: str | None) -> List[str]:\n+    \"\"\"Reconcile config.yaml api_key mirrors that hold ``old_value``.\n+\n+    Value-matched on purpose: we only touch a config entry when it provably\n+    holds the SAME credential that just changed in ``.env`` — an independent\n+    key the user configured for a different endpoint is left alone.\n+\n+    ``new_value=None`` removes the mirror field; a string replaces it.\n+    Operates on the RAW user config (never the defaults-merged view) so the\n+    write doesn't bake defaults into the user's file. Returns the dotted\n+    paths that were updated (names only — never values).\n+    \"\"\"\n+    if not old_value:\n+        return []\n+    from utils import atomic_yaml_write, fast_safe_load\n+\n+    from hermes_cli.config import (\n+        get_config_path,\n+        require_readable_config_before_write,\n+    )\n+\n+    config_path = get_config_path()\n+    if not config_path.exists():\n+        return []\n+    try:\n+        with open(config_path, encoding=\"utf-8\") as f:\n+            user_config = fast_safe_load(f) or {}\n+    except Exception:\n+        return []\n+    if not isinstance(user_config, dict):\n+        return []\n+\n+    touched: List[str] = []\n+\n+    def _fix(section: Any, key_path: str) -> None:\n+        if not isinstance(section, dict):\n+            return\n+        # \"api\" is the legacy alias for model.api_key kept by older configs.\n+        for field in (\"api_key\", \"api\"):\n+            current = section.get(field)\n+            if isinstance(current, str) and current == old_value:\n+                if new_value:\n+                    section[field] = new_value\n+                else:\n+                    section.pop(field, None)\n+                touched.append(f\"{key_path}.{field}\")\n+\n+    _fix(user_config.get(\"model\"), \"model\")\n+\n+    aux = user_config.get(\"auxiliary\")\n+    if isinstance(aux, dict):\n+        for task, slot_cfg in aux.items():\n+            _fix(slot_cfg, f\"auxiliary.{task}\")\n+\n+    custom = user_config.get(\"custom_providers\")\n+    if isinstance(custom, list):\n+        for idx, entry in enumerate(custom):\n+            _fix(entry, f\"custom_providers.{idx}\")\n+    elif isinstance(custom, dict):\n+        for name, entry in custom.items():\n+            _fix(entry, f\"custom_providers.{name}\")\n+\n+    if touched:\n+        require_readable_config_before_write(config_path)\n+        atomic_yaml_write(config_path, user_config, sort_keys=False)\n+    return touched\n+\n+\n+def purge_env_credential_references(\n+    env_var: str, *, clear_models_cache: bool = True\n+) -> Dict[str, Any]:\n+    \"\"\"Remove non-.env references to an env-var credential.\n+\n+    Prunes ``credential_pool`` env-seeded entries and (optionally) the\n+    affected providers' rows in ``provider_models_cache.json`` so the model\n+    picker stops advertising a provider whose key is gone (#59761).\n+    \"\"\"\n+    pruned = _prune_env_pool_entries(env_var)\n+    providers = sorted(set(pruned) | set(_providers_for_env_var(env_var)))\n+    # Make the removal sticky the same way `hermes auth remove` does: a\n+    # lingering shell export (or another live process's os.environ) would\n+    # otherwise re-seed the pool entry on the next load_pool(). The matching\n+    # save path lifts the suppression on an explicit re-add.\n+    try:\n+        from hermes_cli.auth import suppress_credential_source\n+\n+        for provider in providers:\n+            suppress_credential_source(provider, f\"env:{env_var}\")\n+    except Exception:\n+        pass\n+    if clear_models_cache and providers:\n+        try:\n+            from hermes_cli.models import clear_provider_models_cache\n+\n+            for provider in providers:\n+                clear_provider_models_cache(provider)\n+        except Exception:\n+            # Cache cleanup is best-effort — a failure here must not block\n+            # the credential removal itself.\n+            pass\n+    return {\"pool_pruned\": pruned, \"providers\": providers}\n+\n+\n+def save_provider_env_credential(env_var: str, value: str) -> Dict[str, Any]:\n+    \"\"\"Save/update a credential in ``.env`` and reconcile every mirror.\n+\n+    After the ``.env`` write, any config.yaml mirror that held the PREVIOUS\n+    value of this var (``model.api_key`` etc.) is updated to the new value so\n+    a stale higher-precedence copy cannot shadow the rotation (#62269).\n+    Suppressed ``env:<VAR>`` pool sources are re-enabled so a deliberate\n+    re-add through the UI behaves like ``hermes auth add``.\n+    \"\"\"\n+    from hermes_cli.config import load_env, save_env_value\n+\n+    old_value = load_env().get(env_var)\n+    save_env_value(env_var, value)\n+\n+    config_updates: List[str] = []\n+    if value and old_value and old_value != value:\n+        config_updates = _scrub_config_yaml_mirrors(old_value, value)\n+\n+    # A prior UI/CLI removal may have suppressed this env source; a fresh\n+    # save is an explicit re-add, so lift the suppression for every provider\n+    # that reads this var.\n+    try:\n+        from hermes_cli.auth import unsuppress_credential_source\n+\n+        for provider in _providers_for_env_var(env_var):\n+            unsuppress_credential_source(provider, f\"env:{env_var}\")\n+    except Exception:\n+        pass\n+\n+    return {\"ok\": True, \"key\": env_var, \"config_updates\": config_updates}\n+\n+\n+def remove_provider_env_credential(env_var: str) -> Dict[str, Any]:\n+    \"\"\"Remove a credential from EVERY store it lives in.\n+\n+    Clears the ``.env`` entry (and process env), prunes env-seeded\n+    ``credential_pool`` entries, drops the affected providers' model-cache\n+    rows, and removes any config.yaml mirror holding the same value.\n+    OAuth/device-code/manual credentials are preserved (see module docstring).\n+\n+    ``found`` is True when ANY store held the credential — callers that\n+    previously 404'd on \".env miss\" should key off this instead so a stale\n+    pool-only entry can still be cleaned up through the same button.\n+    \"\"\"\n+    from hermes_cli.config import load_env, remove_env_value\n+\n+    old_value = load_env().get(env_var)\n+    removed_from_env = remove_env_value(env_var)\n+    refs = purge_env_credential_references(env_var)\n+    config_scrubbed = _scrub_config_yaml_mirrors(old_value, None) if old_value else []\n+\n+    return {\n+        \"ok\": True,\n+        \"key\": env_var,\n+        \"removed\": removed_from_env,\n+        \"pool_pruned\": refs[\"pool_pruned\"],\n+        \"providers\": refs[\"providers\"],\n+        \"config_scrubbed\": config_scrubbed,\n+        \"found\": bool(removed_from_env or refs[\"pool_pruned\"] or config_scrubbed),\n+    }"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -6354,8 +6354,15 @@ def _row(var_name: str, info: dict, *, custom: bool = False) -> dict:\n async def set_env_var(body: EnvVarUpdate, profile: Optional[str] = None):\n     try:\n         with _profile_scope(body.profile or profile):\n-            save_env_value(body.key, body.value)\n-        return {\"ok\": True, \"key\": body.key}\n+            # Unified credential lifecycle: writes .env AND reconciles any\n+            # config.yaml mirror still holding the previous value of this var\n+            # (model.api_key / auxiliary.*.api_key / custom_providers[*]),\n+            # so a rotation can't leave a stale higher-precedence copy that\n+            # keeps authenticating with the old key (#62269).\n+            from hermes_cli.credential_lifecycle import save_provider_env_credential\n+\n+            result = save_provider_env_credential(body.key, body.value)\n+        return result\n     except ValueError as exc:\n         # save_env_value raises ValueError for invalid names and for keys\n         # on the denylist (LD_PRELOAD, PATH, PYTHONPATH, …). Surface the","@@ -6473,10 +6480,18 @@ async def validate_provider_credential(body: EnvVarUpdate, request: Request):\n async def remove_env_var(body: EnvVarDelete, profile: Optional[str] = None):\n     try:\n         with _profile_scope(body.profile or profile):\n-            removed = remove_env_value(body.key)\n-        if not removed:\n+            # Unified credential lifecycle: clears the .env entry AND every\n+            # mirror of the credential — env-seeded credential_pool entries in\n+            # auth.json (stale ones kept providers alive in the model picker,\n+            # #51071/#59761), the affected providers' model-cache rows, and\n+            # value-matched config.yaml api_key mirrors. OAuth/device-code/\n+            # manual pool entries for the same provider are preserved.\n+            from hermes_cli.credential_lifecycle import remove_provider_env_credential\n+\n+            result = remove_provider_env_credential(body.key)\n+        if not result.get(\"found\"):\n             raise HTTPException(status_code=404, detail=f\"{body.key} not found in .env\")\n-        return {\"ok\": True, \"key\": body.key}\n+        return result\n     except HTTPException:\n         raise\n     except ValueError as exc:"]},{"file":"tests/hermes_cli/test_credential_lifecycle.py","hunks":["@@ -0,0 +1,335 @@\n+\"\"\"E2E tests for the unified provider-credential lifecycle (#51071 #59761 #62269).\n+\n+A provider API key can live in .env, auth.json's credential_pool, and\n+config.yaml mirrors at once. These tests drive the REAL dashboard endpoint\n+handlers (PUT/DELETE /api/env) against real on-disk fixtures in a temp\n+HERMES_HOME (tests/conftest.py isolation) and assert every store agrees\n+afterwards.\n+\n+All fake secrets are constructed at runtime so no key-shaped literal ever\n+lands in the repo.\n+\"\"\"\n+\n+import json\n+\n+import pytest\n+from fastapi.testclient import TestClient\n+\n+from hermes_cli.web_server import _SESSION_TOKEN, app\n+\n+client = TestClient(app)\n+HEADERS = {\"X-Hermes-Session-Token\": _SESSION_TOKEN}\n+\n+# Runtime-constructed fake credentials (never literal key-shaped strings).\n+FAKE_ZAI_KEY = \"zk-\" + \"a\" * 24\n+FAKE_OAUTH_TOKEN = \"oa-\" + \"b\" * 24\n+NEW_KEY = \"zk-\" + \"c\" * 24\n+\n+\n[redacted:email]\n+def hermes_home(monkeypatch, tmp_path):\n+    \"\"\"Fresh HERMES_HOME with .env + auth.json + config.yaml fixtures.\"\"\"\n+    home = tmp_path / \"cred_home\"\n+    home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    from hermes_cli.config import invalidate_env_cache\n+\n+    invalidate_env_cache()\n+    return home\n+\n+\n+def _write_env(home, **pairs):\n+    home.joinpath(\".env\").write_text(\n+        \"\".join(f\"{k}={v}\\n\" for k, v in pairs.items()), encoding=\"utf-8\"\n+    )\n+    from hermes_cli.config import invalidate_env_cache\n+\n+    invalidate_env_cache()\n+\n+\n+def _write_auth(home, pool):\n+    home.joinpath(\"auth.json\").write_text(\n+        json.dumps({\"credential_pool\": pool}), encoding=\"utf-8\"\n+    )\n+\n+\n+def _read_auth(home):\n+    return json.loads(home.joinpath(\"auth.json\").read_text(encoding=\"utf-8\"))\n+\n+\n+def _zai_pool_fixture():\n+    \"\"\"One env-seeded API-key entry plus one OAuth entry for the same provider.\"\"\"\n+    return {\n+        \"zai\": [\n+            {\n+                \"id\": \"e1\",\n+                \"label\": \"env\",\n+                \"auth_type\": \"api_key\",\n+                \"priority\": 0,\n+                \"source\": \"env:ZAI_API_KEY\",\n+                \"access_token\": FAKE_ZAI_KEY,\n+            },\n+            {\n+                \"id\": \"o1\",\n+                \"label\": \"oauth\",\n+                \"auth_type\": \"oauth\",\n+                \"priority\": 0,\n+                \"source\": \"device_code\",\n+                \"access_token\": FAKE_OAUTH_TOKEN,\n+                \"refresh_token\": \"rt-\" + \"d\" * 16,\n+            },\n+        ]\n+    }\n+\n+\n+# ---------------------------------------------------------------------------\n+# DELETE — #51071 / #59761: stale credential_pool entries must be pruned\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_delete_env_key_prunes_env_seeded_pool_entry(hermes_home):\n+    _write_env(hermes_home, ZAI_API_KEY=[redacted:secret-assignment]\n+    _write_auth(hermes_home, _zai_pool_fixture())\n+\n+    resp = client.request(\n+        \"DELETE\", \"/api/env\", json={\"key\": \"ZAI_API_KEY\"}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200\n+    body = resp.json()\n+    assert body[\"ok\"] is True\n+    assert \"zai\" in body[\"pool_pruned\"]\n+\n+    # .env cleared\n+    from hermes_cli.config import load_env\n+\n+    assert \"ZAI_API_KEY\" not in load_env()\n+\n+    # auth.json: env-seeded entry gone, OAuth entry preserved\n+    store = _read_auth(hermes_home)\n+    sources = [e[\"source\"] for e in store[\"credential_pool\"][\"zai\"]]\n+    assert \"env:ZAI_API_KEY\" not in sources\n+    assert \"device_code\" in sources, \"OAuth grant must survive an API-key delete\"\n+\n+\n+def test_delete_env_key_removes_provider_pool_key_when_emptied(hermes_home):\n+    \"\"\"A provider whose ONLY pool entry was env-seeded disappears entirely.\"\"\"\n+    _write_env(hermes_home, ZAI_API_KEY=[redacted:secret-assignment]\n+    _write_auth(\n+        hermes_home,\n+        {\"zai\": [_zai_pool_fixture()[\"zai\"][0]]},  # env entry only\n+    )\n+\n+    resp = client.request(\n+        \"DELETE\", \"/api/env\", json={\"key\": \"ZAI_API_KEY\"}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200\n+    store = _read_auth(hermes_home)\n+    assert \"zai\" not in store.get(\"credential_pool\", {}), (\n+        \"provider must vanish from credential_pool so the model picker \"\n+        \"stops listing it (#51071)\"\n+    )\n+\n+\n+def test_delete_survives_pool_reload(hermes_home):\n+    \"\"\"#59761: the pool loader must not resurrect the entry after 'restart'.\"\"\"\n+    _write_env(hermes_home, ZAI_API_KEY=[redacted:secret-assignment]\n+    _write_auth(hermes_home, {\"zai\": [_zai_pool_fixture()[\"zai\"][0]]})\n+\n+    resp = client.request(\n+        \"DELETE\", \"/api/env\", json={\"key\": \"ZAI_API_KEY\"}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200\n+\n+    # Simulate restart: reload the pool from disk the way startup does.\n+    from agent.credential_pool import load_pool\n+\n+    entries = load_pool(\"zai\").entries()\n+    assert entries == [], f\"stale entries resurrected: {[e.source for e in entries]}\"\n+\n+\n+def test_delete_clears_provider_models_cache(hermes_home):\n+    _write_env(hermes_home, ZAI_API_KEY=[redacted:secret-assignment]\n+    _write_auth(hermes_home, {\"zai\": [_zai_pool_fixture()[\"zai\"][0]]})\n+    cache_path = hermes_home / \"provider_models_cache.json\"\n+    cache_path.write_text(\n+        json.dumps({\"zai\": {\"models\": [\"glm-5\"], \"ts\": 0}}), encoding=\"utf-8\"\n+    )\n+\n+    resp = client.request(\n+        \"DELETE\", \"/api/env\", json={\"key\": \"ZAI_API_KEY\"}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200\n+    if cache_path.exists():\n+        cache = json.loads(cache_path.read_text(encoding=\"utf-8\"))\n+        assert \"zai\" not in cache\n+\n+\n+def test_delete_pool_only_credential_still_cleans_up(hermes_home):\n+    \"\"\"Stale pool entry with NO .env line (the #59761 restart state) is\n+    removable through the same delete button instead of 404ing.\"\"\"\n+    _write_env(hermes_home)  # empty .env\n+    _write_auth(hermes_home, {\"zai\": [_zai_pool_fixture()[\"zai\"][0]]})\n+\n+    resp = client.request(\n+        \"DELETE\", \"/api/env\", json={\"key\": \"ZAI_API_KEY\"}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200\n+    store = _read_auth(hermes_home)\n+    assert \"zai\" not in store.get(\"credential_pool\", {})\n+\n+\n+def test_delete_unknown_key_404s(hermes_home):\n+    _write_env(hermes_home)\n+    resp = client.request(\n+        \"DELETE\", \"/api/env\", json={\"key\": \"NEVER_SET_KEY\"}, headers=HEADERS\n+    )\n+    assert resp.status_code == 404\n+\n+\n+def test_delete_does_not_touch_other_providers(hermes_home):\n+    _write_env(hermes_home, ZAI_API_KEY=[redacted:secret-assignment]\n+    other_key = \"dk-\" + \"e\" * 24\n+    pool = _zai_pool_fixture()\n+    pool[\"deepseek\"] = [\n+        {\n+            \"id\": \"d1\",\n+            \"label\": \"env\",\n+            \"auth_type\": \"api_key\",\n+            \"priority\": 0,\n+            \"source\": \"env:DEEPSEEK_API_KEY\",\n+            \"access_token\": other_key,\n+        }\n+    ]\n+    _write_auth(hermes_home, pool)\n+\n+    resp = client.request(\n+        \"DELETE\", \"/api/env\", json={\"key\": \"ZAI_API_KEY\"}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200\n+    store = _read_auth(hermes_home)\n+    assert [e[\"source\"] for e in store[\"credential_pool\"][\"deepseek\"]] == [\n+        \"env:DEEPSEEK_API_KEY\"\n+    ]\n+\n+\n+# ---------------------------------------------------------------------------\n+# UPDATE — #62269: config.yaml mirrors of the old key must rotate with .env\n+# ---------------------------------------------------------------------------\n+\n+\n+def _write_config(home, text):\n+    home.joinpath(\"config.yaml\").write_text(text, encoding=\"utf-8\")\n+\n+\n+def test_update_rotates_config_yaml_model_mirror(hermes_home):\n+    old = \"sk-oe-\" + \"f\" * 24\n+    new = \"sk-oe-\" + \"g\" * 24\n+    _write_env(hermes_home, OPENAI_API_KEY=old)\n+    _write_config(\n+        hermes_home,\n+        \"model:\\n\"\n+        \"  provider: custom\\n\"\n+        \"  default: my-model\\n\"\n+        \"  base_url: https://llm.example.test/v1\\n\"\n+        f\"  api_key: [redacted:secret-assignment]\",\n+    )\n+\n+    resp = client.put(\n+        \"/api/env\", json={\"key\": \"OPENAI_API_KEY\", \"value\": new}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200\n+    assert \"model.api_key\" in resp.json().get(\"config_updates\", [])\n+\n+    cfg_text = hermes_home.joinpath(\"config.yaml\").read_text(encoding=\"utf-8\")\n+    assert old not in cfg_text, \"stale old key left in config.yaml (#62269)\"\n+    assert new in cfg_text, \"config.yaml mirror not rotated to the new key\"\n+\n+    from hermes_cli.config import load_env\n+\n+    assert load_env()[\"OPENAI_API_KEY\"] == new\n+\n+\n+def test_update_rotates_custom_provider_mirror(hermes_home):\n+    old = \"sk-cp-\" + \"h\" * 24\n+    new = \"sk-cp-\" + \"i\" * 24\n+    _write_env(hermes_home, OPENAI_API_KEY=old)\n+    _write_config(\n+        hermes_home,\n+        \"custom_providers:\\n\"\n+        \"  - name: myendpoint\\n\"\n+        \"    base_url: https://llm.example.test/v1\\n\"\n+        f\"    api_key: [redacted:secret-assignment]\",\n+    )\n+\n+    resp = client.put(\n+        \"/api/env\", json={\"key\": \"OPENAI_API_KEY\", \"value\": new}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200\n+    cfg_text = hermes_home.joinpath(\"config.yaml\").read_text(encoding=\"utf-8\")\n+    assert old not in cfg_text\n+    assert new in cfg_text\n+\n+\n+def test_update_leaves_unrelated_config_keys_alone(hermes_home):\n+    \"\"\"A DIFFERENT key configured inline must not be rewritten by value-match.\"\"\"\n+    old = \"sk-un-\" + \"j\" * 24\n+    unrelated = \"sk-un-\" + \"k\" * 24\n+    _write_env(hermes_home, OPENAI_API_KEY=old)\n+    _write_config(hermes_home, f\"model:\\n  provider: custom\\n  api_key: [redacted:secret-assignment]\")\n+\n+    resp = client.put(\n+        \"/api/env\",\n+        json={\"key\": \"OPENAI_API_KEY\", \"value\": \"sk-un-\" + \"l\" * 24},\n+        headers=HEADERS,\n+    )\n+    assert resp.status_code == 200\n+    cfg_text = hermes_home.joinpath(\"config.yaml\").read_text(encoding=\"utf-8\")\n+    assert unrelated in cfg_text, \"unrelated inline key must be preserved\"\n+\n+\n+def test_delete_scrubs_config_yaml_mirror(hermes_home):\n+    old = \"sk-dl-\" + \"m\" * 24\n+    _write_env(hermes_home, OPENAI_API_KEY=old)\n+    _write_config(hermes_home, f\"model:\\n  provider: custom\\n  api_key: [redacted:secret-assignment]\")\n+\n+    resp = client.request(\n+        \"DELETE\", \"/api/env\", json={\"key\": \"OPENAI_API_KEY\"}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200\n+    assert \"model.api_key\" in resp.json()[\"config_scrubbed\"]\n+    cfg_text = hermes_home.joinpath(\"config.yaml\").read_text(encoding=\"utf-8\")\n+    assert old not in cfg_text\n+\n+\n+# ---------------------------------------------------------------------------\n+# Suppression round-trip: delete sticks, re-add lifts it\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_delete_then_resave_round_trip(hermes_home):\n+    _write_env(hermes_home, ZAI_API_KEY=[redacted:secret-assignment]\n+    _write_auth(hermes_home, {\"zai\": [_zai_pool_fixture()[\"zai\"][0]]})\n+\n+    resp = client.request(\n+        \"DELETE\", \"/api/env\", json={\"key\": \"ZAI_API_KEY\"}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200\n+\n+    from hermes_cli.auth import is_source_suppressed\n+\n+    assert is_source_suppressed(\"zai\", \"env:ZAI_API_KEY\"), (\n+        \"delete must suppress the env source so a lingering shell export \"\n+        \"can't re-seed the pool\"\n+    )\n+\n+    resp = client.put(\n+        \"/api/env\", json={\"key\": \"ZAI_API_KEY\", \"value\": NEW_KEY}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200\n+    assert not is_source_suppressed(\"zai\", \"env:ZAI_API_KEY\"), (\n+        \"an explicit re-save must lift the suppression (like `hermes auth add`)\"\n+    )\n+\n+    from hermes_cli.config import load_env\n+\n+    assert load_env()[\"ZAI_API_KEY\"] == NEW_KEY"]},{"file":"tests/hermes_cli/test_env_export_line_lifecycle.py","hunks":["@@ -0,0 +1,125 @@\n+\"\"\"Regression tests for the Tools & Keys GitHub PAT save/remove path (#40041).\n+\n+Users following generic docs add ``export GITHUB_TOKEN=[redacted:secret-assignment] to\n+``~/.hermes/.env``. ``load_env()`` parses the export prefix (#6659), so every\n+UI shows the token as set (green light) — but ``save_env_value`` /\n+``remove_env_value`` only matched plain ``KEY=`` lines. Result: the UI could\n+neither replace nor remove the token (delete 404s as \"not found in .env\";\n+save appends a duplicate line that a later delete removes while the export\n+line silently resurrects the old value).\n+\n+Fake tokens are constructed at runtime — no key-shaped literals on disk.\n+\"\"\"\n+\n+import pytest\n+from fastapi.testclient import TestClient\n+\n+from hermes_cli.web_server import _SESSION_TOKEN, app\n+\n+client = TestClient(app)\n+HEADERS = {\"X-Hermes-Session-Token\": _SESSION_TOKEN}\n+\n+# Classic-PAT-shaped token, constructed at runtime (36 chars after prefix).\n+OLD_PAT = \"ghp_\" + \"A\" * 36\n+NEW_PAT = \"ghp_\" + \"B\" * 36\n+\n+\n[redacted:email]\n+def hermes_home(monkeypatch, tmp_path):\n+    home = tmp_path / \"pat_home\"\n+    home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    from hermes_cli.config import invalidate_env_cache\n+\n+    invalidate_env_cache()\n+    return home\n+\n+\n+def _write_env_raw(home, text):\n+    home.joinpath(\".env\").write_text(text, encoding=\"utf-8\")\n+    from hermes_cli.config import invalidate_env_cache\n+\n+    invalidate_env_cache()\n+\n+\n+def test_classic_pat_save_via_endpoint_succeeds(hermes_home):\n+    \"\"\"Saving a ghp_* classic PAT through the env endpoint must not 500 —\n+    GITHUB_TOKEN is a REST/Skills-Hub credential, not a Copilot one.\"\"\"\n+    resp = client.put(\n+        \"/api/env\", json={\"key\": \"GITHUB_TOKEN\", \"value\": NEW_PAT}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200, resp.text\n+\n+    from hermes_cli.config import load_env\n+\n+    assert load_env()[\"GITHUB_TOKEN\"] == NEW_PAT\n+\n+\n+def test_remove_export_prefixed_token(hermes_home):\n+    \"\"\"DELETE must clear an ``export KEY=...`` line, not 404 on it.\"\"\"\n+    _write_env_raw(hermes_home, f\"export GITHUB_TOKEN=[redacted:secret-assignment]\")\n+\n+    resp = client.request(\n+        \"DELETE\", \"/api/env\", json={\"key\": \"GITHUB_TOKEN\"}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200, (\n+        \"export-prefixed lines are parsed by load_env (UI shows the token as \"\n+        \"set) so the delete path must recognise them too (#40041)\"\n+    )\n+\n+    env_text = hermes_home.joinpath(\".env\").read_text(encoding=\"utf-8\")\n+    assert OLD_PAT not in env_text\n+\n+    from hermes_cli.config import load_env\n+\n+    assert \"GITHUB_TOKEN\" not in load_env()\n+\n+\n+def test_update_export_prefixed_token_does_not_duplicate(hermes_home):\n+    \"\"\"Saving over an ``export KEY=`` line must replace it in place.\"\"\"\n+    _write_env_raw(hermes_home, f\"export GITHUB_TOKEN=[redacted:secret-assignment]\")\n+\n+    resp = client.put(\n+        \"/api/env\", json={\"key\": \"GITHUB_TOKEN\", \"value\": NEW_PAT}, headers=HEADERS\n+    )\n+    assert resp.status_code == 200\n+\n+    env_text = hermes_home.joinpath(\".env\").read_text(encoding=\"utf-8\")\n+    assert OLD_PAT not in env_text, \"old exported token line must be replaced\"\n+    assert env_text.count(\"GITHUB_TOKEN\") == 1, (\n+        \"save must not append a duplicate GITHUB_TOKEN line alongside the \"\n+        \"export-prefixed one\"\n+    )\n+\n+    from hermes_cli.config import load_env\n+\n+    assert load_env()[\"GITHUB_TOKEN\"] == NEW_PAT\n+\n+\n+def test_plain_line_save_and_remove_still_work(hermes_home):\n+    \"\"\"Sanity: the ordinary KEY= path is unchanged.\"\"\"\n+    from hermes_cli.config import load_env, remove_env_value, save_env_value\n+\n+    save_env_value(\"GITHUB_TOKEN\", OLD_PAT)\n+    assert load_env()[\"GITHUB_TOKEN\"] == OLD_PAT\n+    save_env_value(\"GITHUB_TOKEN\", NEW_PAT)\n+    env_text = hermes_home.joinpath(\".env\").read_text(encoding=\"utf-8\")\n+    assert env_text.count(\"GITHUB_TOKEN\") == 1\n+    assert remove_env_value(\"GITHUB_TOKEN\") is True\n+    assert \"GITHUB_TOKEN\" not in load_env()\n+\n+\n+def test_export_line_with_comment_untouched(hermes_home):\n+    \"\"\"Commented-out export lines are not live assignments — leave them.\"\"\"\n+    _write_env_raw(\n+        hermes_home,\n+        f\"# export GITHUB_TOKEN=[redacted:secret-assignment]\",\n+    )\n+\n+    resp = client.request(\n+        \"DELETE\", \"/api/env\", json={\"key\": \"GITHUB_TOKEN\"}, headers=HEADERS\n+    )\n+    assert resp.status_code == 404\n+    env_text = hermes_home.joinpath(\".env\").read_text(encoding=\"utf-8\")\n+    assert \"# export GITHUB_TOKEN=\" in env_text\n+    assert \"OTHER_KEY=value\" in env_text"]},{"file":"tui_gateway/server.py","hunks":["@@ -13984,7 +13984,7 @@ def _(rid, params: dict) -> dict:\n     \"\"\"\n     try:\n         from hermes_cli.auth import PROVIDER_REGISTRY\n-        from hermes_cli.config import is_managed, save_env_value\n+        from hermes_cli.config import is_managed\n         from hermes_cli.inventory import build_models_payload, load_picker_context\n \n         slug = (params.get(\"slug\") or \"\").strip()","@@ -14008,9 +14008,13 @@ def _(rid, params: dict) -> dict:\n         if not pconfig.api_key_env_vars:\n             return _err(rid, 4004, f\"no env var defined for {pconfig.name}\")\n \n-        # Save the key to ~/.hermes/.env\n+        # Save the key to ~/.hermes/.env via the unified credential lifecycle\n+        # so any stale config.yaml mirror of the previous key (model.api_key,\n+        # custom_providers[*].api_key) is rotated in the same action (#62269).\n         env_var = pconfig.api_key_env_vars[0]\n-        save_env_value(env_var, api_key)\n+        from hermes_cli.credential_lifecycle import save_provider_env_credential\n+\n+        save_provider_env_credential(env_var, api_key)\n         # Also set in current process so the refreshed inventory sees it.\n         import os\n ","@@ -14064,7 +14068,7 @@ def _(rid, params: dict) -> dict:\n     \"\"\"\n     try:\n         from hermes_cli.auth import PROVIDER_REGISTRY, clear_provider_auth\n-        from hermes_cli.config import remove_env_value\n+        from hermes_cli.credential_lifecycle import remove_provider_env_credential\n \n         slug = (params.get(\"slug\") or \"\").strip()\n         if not slug:","@@ -14074,13 +14078,19 @@ def _(rid, params: dict) -> dict:\n         cleared_env = False\n         cleared_auth = False\n \n-        # Remove API key env vars from .env and process\n+        # Remove API key env vars from .env and process, plus every mirror\n+        # (env-seeded credential_pool entries, provider model cache rows,\n+        # value-matched config.yaml api_key copies) via the unified helper —\n+        # otherwise the provider resurrects in the picker after restart\n+        # (#51071 / #59761).\n         if pconfig and pconfig.api_key_env_vars:\n             for ev in pconfig.api_key_env_vars:\n-                if remove_env_value(ev):\n+                if remove_provider_env_credential(ev).get(\"found\"):\n                     cleared_env = True\n \n-        # Clear OAuth / credential pool state\n+        # Clear OAuth / credential pool state. This is a full provider\n+        # disconnect (TUI \"disconnect\" action), so removing OAuth grants\n+        # here is the documented intent — unlike the key-only delete paths.\n         cleared_auth = clear_provider_auth(slug)\n \n         if not cleared_env and not cleared_auth:\n"]}]},{"kind":"pr","number":69488,"title":"fix(web_server): persist full model list from Test, key_env for Desktop, IP guard","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#69449","number":69449},{"kind":"pr","value":"#69988","number":69988},{"kind":"pr","value":"#71141","number":71141}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":70722,"title":"fix: custom endpoint API key stored in plaintext in config.yaml (#69449)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#69449","number":69449},{"kind":"pr","value":"#71141","number":71141}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":71141,"title":"fix: custom endpoint keys go to .env, and Save keeps the whole model list","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/app/settings/custom-endpoints-settings.tsx","apps/desktop/src/types/hermes.ts","hermes_cli/config.py","hermes_cli/main.py","hermes_cli/model_setup_flows.py","hermes_cli/web_server.py","tests/cli/test_cli_provider_resolution.py","tests/hermes_cli/test_web_server.py"]},{"kind":"pr","number":80927,"title":"fix(security): keep custom endpoint API keys out of config.yaml by using key_env","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"12883cb7b551f18b06d5f063df9c9477567b6e7d","number":0},{"kind":"commit","value":"865ba906c1a8d93de65839ee7af487204d42e873","number":0},{"kind":"commit","value":"61b7f957bac7fa25c7c22d54e4b215fa446e5331","number":0},{"kind":"commit","value":"d22d1034e0c274a7224670e7aa6d32e2d841804f","number":0},{"kind":"pr","value":"#92125","number":92125},{"kind":"pr","value":"#78086","number":78086}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}