{"repo":"NousResearch/hermes-agent","public_id":"55217","display_label":"Auth credential removal undermined by automatic re-seeding","summary":"These issues and fixes center on credential removal operations being silently reversed by automatic re-import from sources like ~/.codex/auth.json or singleton auth stores. Work spans CLI auth commands, dashboard pool deletes, and the openai-codex provider.","subsystem":"dashboard","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":67429,"merged_at":"2026-07-19T10:48:49+00:00"},"resolution":{"state":"resolved","by":"pr-67429"},"freshness":{"state":"verified","last_verified_at":"2026-07-19T10:48:49+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":55217,"pr_number":67429,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-19T10:48:49+00:00"}],"members":[{"kind":"issue","number":55217,"title":"Bug: Dashboard credential pool delete succeeds but entries reappear on refresh","url":null,"state":"closed"},{"kind":"pr","number":55219,"title":"fix(dashboard): suppress credential source on pool delete to prevent resurrection","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#55217","number":55217},{"kind":"pr","value":"#67429","number":67429}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":67429,"title":"fix(credentials): pool entry delete via API sticks — suppress source re-seeding (#55217)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/web_server.py","tests/hermes_cli/test_dashboard_admin_endpoints.py"],"fix_hunks":[{"file":"hermes_cli/web_server.py","hunks":["@@ -12498,6 +12498,7 @@ async def add_credential_pool_entry(body: CredentialPoolAdd):\n         load_pool,\n         PooledCredential,\n         AUTH_TYPE_API_KEY,\n+        CUSTOM_POOL_PREFIX,\n         SOURCE_MANUAL,\n     )\n ","@@ -12519,6 +12520,23 @@ async def add_credential_pool_entry(body: CredentialPoolAdd):\n             access_token=[redacted:secret-assignment]\n         )\n         pool.add_entry(entry)\n+        # Re-adding a credential is an explicit re-engagement signal: lift\n+        # every suppression for this provider so a source deleted earlier\n+        # (via DELETE below or `hermes auth remove`) can seed again.\n+        # Mirrors the `hermes auth add` behaviour in auth_commands.py.\n+        if not provider.startswith(CUSTOM_POOL_PREFIX):\n+            try:\n+                from hermes_cli.auth import (\n+                    _load_auth_store,\n+                    unsuppress_credential_source,\n+                )\n+                suppressed = _load_auth_store().get(\"suppressed_sources\", {})\n+                for src in list(suppressed.get(provider, []) or []):\n+                    unsuppress_credential_source(provider, src)\n+            except Exception:\n+                _log.exception(\"unsuppress after pool add failed (non-fatal)\")\n+    except HTTPException:\n+        raise\n     except Exception as exc:\n         _log.exception(\"POST /api/credentials/pool failed\")\n         raise HTTPException(status_code=400, detail=str(exc)) from exc","@@ -12527,8 +12545,20 @@ async def add_credential_pool_entry(body: CredentialPoolAdd):\n \n @app.delete(\"/api/credentials/pool/{provider}/{index}\")\n async def remove_credential_pool_entry(provider: str, index: int):\n-    \"\"\"Remove a pool entry.  ``index`` is 1-based (matches the list response).\"\"\"\n+    \"\"\"Remove a pool entry.  ``index`` is 1-based (matches the list response).\n+\n+    Removal must be sticky (#55217): ``load_pool()`` re-seeds entries from\n+    their backing source (.env var, OAuth singleton file, custom-provider\n+    config) on every call, so deleting only the pool row silently reverts on\n+    the next dashboard refresh.  We dispatch through the same RemovalStep\n+    registry the CLI ``hermes auth remove`` uses: each source cleans up its\n+    external state and suppresses ``(provider, source)`` so the seeders skip\n+    it.  Manual entries have no registered step — nothing external to clean,\n+    no suppression needed (they aren't re-seeded).\n+    \"\"\"\n     from agent.credential_pool import load_pool\n+    from agent.credential_sources import find_removal_step\n+    from hermes_cli.auth import suppress_credential_source\n \n     provider = (provider or \"\").strip().lower()\n     try:","@@ -12539,7 +12569,36 @@ async def remove_credential_pool_entry(provider: str, index: int):\n         raise HTTPException(status_code=400, detail=str(exc)) from exc\n     if removed is None:\n         raise HTTPException(status_code=404, detail=\"No pool entry at that index\")\n-    return {\"ok\": True, \"provider\": provider, \"count\": len(pool.entries())}\n+\n+    cleaned: List[str] = []\n+    hints: List[str] = []\n+    step = find_removal_step(provider, removed.source or \"\")\n+    if step is not None:\n+        try:\n+            result = step.remove_fn(provider, removed)\n+            cleaned = list(result.cleaned)\n+            hints = list(result.hints)\n+            if result.suppress:\n+                suppress_credential_source(provider, removed.source)\n+        except Exception:\n+            # Cleanup is best-effort, but suppression is the actual bug fix —\n+            # without it the entry resurrects on the next load_pool().  Apply\n+            # it even when source-specific cleanup blew up.\n+            _log.exception(\n+                \"credential source cleanup failed for %s/%s; suppressing anyway\",\n+                provider, removed.source,\n+            )\n+            try:\n+                suppress_credential_source(provider, removed.source)\n+            except Exception:\n+                _log.exception(\"suppress_credential_source failed\")\n+    return {\n+        \"ok\": True,\n+        \"provider\": provider,\n+        \"count\": len(pool.entries()),\n+        \"cleaned\": cleaned,\n+        \"hints\": hints,\n+    }\n \n \n # ---------------------------------------------------------------------------"]},{"file":"tests/hermes_cli/test_dashboard_admin_endpoints.py","hunks":["@@ -253,6 +253,104 @@ def test_empty_body_rejected(self):\n         )\n         assert r.status_code == 400\n \n+    def test_env_seeded_delete_stays_deleted(self):\n+        \"\"\"#55217: DELETE must suppress the source or load_pool() resurrects it.\n+\n+        load_pool() re-seeds from ~/.hermes/.env on every call, so removing\n+        just the pool row silently reverts on the next dashboard refresh.\n+        The endpoint must mirror `hermes auth remove`: clean up the backing\n+        source and suppress (provider, source).\n+        \"\"\"\n+        from agent.credential_pool import load_pool\n+        from hermes_cli.auth import is_source_suppressed\n+        from hermes_cli.config import save_env_value\n+\n+        fake_key = \"sk-or-\" + \"x\" * 20  # constructed, never a real key shape\n+        save_env_value(\"OPENROUTER_API_KEY\", fake_key)\n+\n+        entries = load_pool(\"openrouter\").entries()\n+        assert [e.source for e in entries] == [\"env:OPENROUTER_API_KEY\"]\n+\n+        r = self.client.delete(\"/api/credentials/pool/openrouter/1\")\n+        assert r.status_code == 200\n+\n+        # Suppressed exactly like the CLI removal path.\n+        assert is_source_suppressed(\"openrouter\", \"env:OPENROUTER_API_KEY\")\n+\n+        # Even if the backing var comes back (shell export, another process\n+        # rewriting .env), the removal must stay sticky.\n+        save_env_value(\"OPENROUTER_API_KEY\", fake_key)\n+        assert load_pool(\"openrouter\").entries() == []\n+        assert self.client.get(\"/api/credentials/pool\").json()[\"providers\"] == []\n+\n+    def test_post_readd_lifts_suppression(self):\n+        \"\"\"Re-adding via POST is an explicit re-engagement — suppressions lift.\n+\n+        Mirrors `hermes auth add`, which clears every suppression for the\n+        provider so a user who deleted a credential and re-adds one isn't\n+        silently blocked from env re-seeding.\n+        \"\"\"\n+        from agent.credential_pool import load_pool\n+        from hermes_cli.auth import is_source_suppressed\n+        from hermes_cli.config import save_env_value\n+\n+        fake_key = \"sk-or-\" + \"y\" * 20\n+        save_env_value(\"OPENROUTER_API_KEY\", fake_key)\n+        load_pool(\"openrouter\")\n+        assert self.client.delete(\"/api/credentials/pool/openrouter/1\").status_code == 200\n+        assert is_source_suppressed(\"openrouter\", \"env:OPENROUTER_API_KEY\")\n+\n+        r = self.client.post(\n+            \"/api/credentials/pool\",\n+            json={\"provider\": \"openrouter\", \"api_key\": \"[redacted:secret-assignment]\" + \"z\" * 20},\n+        )\n+        assert r.status_code == 200\n+        assert not is_source_suppressed(\"openrouter\", \"env:OPENROUTER_API_KEY\")\n+\n+        # Key back in .env + suppression lifted → env entry seeds alongside\n+        # the manual one.\n+        save_env_value(\"OPENROUTER_API_KEY\", fake_key)\n+        sources = sorted(e.source for e in load_pool(\"openrouter\").entries())\n+        assert sources == [\"env:OPENROUTER_API_KEY\", \"manual\"]\n+\n+    def test_manual_delete_adds_no_suppression(self):\n+        \"\"\"Manual entries aren't re-seeded — CLI parity: no suppression marker.\"\"\"\n+        from hermes_cli.auth import _load_auth_store\n+\n+        self.client.post(\n+            \"/api/credentials/pool\",\n+            json={\"provider\": \"openrouter\", \"api_key\": \"[redacted:secret-assignment]\" + \"m\" * 20},\n+        )\n+        assert self.client.delete(\"/api/credentials/pool/openrouter/1\").status_code == 200\n+        suppressed = _load_auth_store().get(\"suppressed_sources\", {})\n+        assert not suppressed.get(\"openrouter\")\n+\n+        # Immediate re-add works.\n+        r = self.client.post(\n+            \"/api/credentials/pool\",\n+            json={\"provider\": \"openrouter\", \"api_key\": \"[redacted:secret-assignment]\" + \"n\" * 20},\n+        )\n+        assert r.status_code == 200 and r.json()[\"count\"] == 1\n+\n+    def test_delete_does_not_clobber_other_providers(self):\n+        \"\"\"Deleting one provider's env entry leaves other providers' rows alone.\"\"\"\n+        from agent.credential_pool import load_pool\n+        from hermes_cli.auth import _load_auth_store, read_credential_pool\n+        from hermes_cli.config import save_env_value\n+\n+        self.client.post(\n+            \"/api/credentials/pool\",\n+            json={\"provider\": \"anthropic\", \"api_key\": \"[redacted:secret-assignment]\" + \"k\" * 20},\n+        )\n+        save_env_value(\"OPENROUTER_API_KEY\", \"sk-or-\" + \"q\" * 20)\n+        load_pool(\"openrouter\")\n+\n+        assert self.client.delete(\"/api/credentials/pool/openrouter/1\").status_code == 200\n+\n+        assert len(read_credential_pool(\"anthropic\")) == 1\n+        suppressed = _load_auth_store().get(\"suppressed_sources\", {})\n+        assert list(suppressed.keys()) == [\"openrouter\"]\n+\n \n class TestMemoryEndpoints:\n     @pytest.fixture(autouse=True)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}