{"repo":"NousResearch/hermes-agent","public_id":"9528","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":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12360},"resolution":{"state":"partially_resolved","by":"pr-67429"},"freshness":{"state":"verified","last_verified_at":"2026-07-19T10:48:49+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.16888888888888892},"fix_edges":[{"issue_number":6651,"pr_number":12360,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":6907,"pr_number":12360,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":11364,"pr_number":12360,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":12747,"pr_number":12360,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":55217,"pr_number":67429,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":9528,"title":"openai-codex provider impossible to remove — auto-restore from ~/.codex/auth.json","url":null,"state":"closed"},{"kind":"issue","number":12747,"title":"auth remove openai-codex can re-seed a new device_code credential from ~/.codex/auth.json","url":null,"state":"closed"},{"kind":"issue","number":13967,"title":"auth remove re-seeds manually added OAuth credentials from singleton auth store","url":null,"state":"closed"},{"kind":"issue","number":55217,"title":"Bug: Dashboard credential pool delete succeeds but entries reappear on refresh","url":null,"state":"closed"},{"kind":"pr","number":12360,"title":"fix(codex): Hermes owns its own Codex auth; stop touching ~/.codex/auth.json","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/credential_pool.py","hermes_cli/auth.py","tests/hermes_cli/test_auth_codex_provider.py"],"fix_hunks":[{"file":"agent/credential_pool.py","hunks":["@@ -22,8 +22,6 @@\n     _auth_store_lock,\n     _codex_access_token_is_expiring,\n     _decode_jwt_claims,\n-    _import_codex_cli_tokens,\n-    _write_codex_cli_tokens,\n     _load_auth_store,\n     _load_provider_state,\n     _resolve_kimi_base_url,","@@ -457,39 +455,6 @@ def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) -\n             logger.debug(\"Failed to sync from credentials file: %s\", exc)\n         return entry\n \n-    def _sync_codex_entry_from_cli(self, entry: PooledCredential) -> PooledCredential:\n-        \"\"\"Sync an openai-codex pool entry from ~/.codex/auth.json if tokens differ.\n-\n-        OpenAI OAuth refresh tokens are single-use and rotate on every refresh.\n-        When the Codex CLI (or another Hermes profile) refreshes its token,\n-        the pool entry's refresh_token becomes stale.  This method detects that\n-        by comparing against ~/.codex/auth.json and syncing the fresh pair.\n-        \"\"\"\n-        if self.provider != \"openai-codex\":\n-            return entry\n-        try:\n-            cli_tokens = [redacted:secret-assignment]\n-            if not cli_tokens:\n-                return entry\n-            cli_refresh = cli_tokens.get(\"refresh_token\", \"\")\n-            cli_access = cli_tokens.get(\"access_token\", \"\")\n-            if cli_refresh and cli_refresh != entry.refresh_token:\n-                logger.debug(\"Pool entry %s: syncing tokens from ~/.codex/auth.json (refresh token changed)\", entry.id)\n-                updated = replace(\n-                    entry,\n-                    access_token=[redacted:secret-assignment]\n-                    refresh_token=[redacted:secret-assignment]\n-                    last_status=None,\n-                    last_status_at=None,\n-                    last_error_code=None,\n-                )\n-                self._replace_entry(entry, updated)\n-                self._persist()\n-                return updated\n-        except Exception as exc:\n-            logger.debug(\"Failed to sync from ~/.codex/auth.json: %s\", exc)\n-        return entry\n-\n     def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None:\n         \"\"\"Write refreshed pool entry tokens back to auth.json providers.\n ","@@ -585,13 +550,6 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po\n                     except Exception as wexc:\n                         logger.debug(\"Failed to write refreshed token to credentials file: %s\", wexc)\n             elif self.provider == \"openai-codex\":\n-                # Proactively sync from ~/.codex/auth.json before refresh.\n-                # The Codex CLI (or another Hermes profile) may have already\n-                # consumed our refresh_token.  Syncing first avoids a\n-                # \"refresh_token_reused\" error when the CLI has a newer pair.\n-                synced = self._sync_codex_entry_from_cli(entry)\n-                if synced is not entry:\n-                    entry = synced\n                 refreshed = auth_mod.refresh_codex_oauth_pure(\n                     entry.access_token,\n                     entry.refresh_token,","@@ -677,45 +635,6 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po\n                     # Credentials file had a valid (non-expired) token — use it directly\n                     logger.debug(\"Credentials file has valid token, using without refresh\")\n                     return synced\n-            # For openai-codex: the refresh_token may have been consumed by\n-            # the Codex CLI between our proactive sync and the refresh call.\n-            # Re-sync and retry once.\n-            if self.provider == \"openai-codex\":\n-                synced = self._sync_codex_entry_from_cli(entry)\n-                if synced.refresh_token != entry.refresh_token:\n-                    logger.debug(\"Retrying Codex refresh with synced token from ~/.codex/auth.json\")\n-                    try:\n-                        refreshed = auth_mod.refresh_codex_oauth_pure(\n-                            synced.access_token,\n-                            synced.refresh_token,\n-                        )\n-                        updated = replace(\n-                            synced,\n-                            access_token=[redacted:secret-assignment]\"access_token\"],\n-                            refresh_token=[redacted:secret-assignment]\"refresh_token\"],\n-                            last_refresh=refreshed.get(\"last_refresh\"),\n-                            last_status=STATUS_OK,\n-                            last_status_at=None,\n-                            last_error_code=None,\n-                        )\n-                        self._replace_entry(synced, updated)\n-                        self._persist()\n-                        self._sync_device_code_entry_to_auth_store(updated)\n-                        try:\n-                            _write_codex_cli_tokens(\n-                                updated.access_token,\n-                                updated.refresh_token,\n-                                last_refresh=updated.last_refresh,\n-                            )\n-                        except Exception as wexc:\n-                            logger.debug(\"Failed to write refreshed Codex tokens to CLI file (retry): %s\", wexc)\n-                        return updated\n-                    except Exception as retry_exc:\n-                        logger.debug(\"Codex retry refresh also failed: %s\", retry_exc)\n-                elif not self._entry_needs_refresh(synced):\n-                    logger.debug(\"Codex CLI has valid token, using without refresh\")\n-                    self._sync_device_code_entry_to_auth_store(synced)\n-                    return synced\n             self._mark_exhausted(entry, None)\n             return None\n ","@@ -734,17 +653,6 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po\n         # _seed_from_singletons() on the next load_pool() sees fresh state\n         # instead of re-seeding stale/consumed tokens.\n         self._sync_device_code_entry_to_auth_store(updated)\n-        # Write refreshed tokens back to ~/.codex/auth.json so Codex CLI\n-        # and VS Code don't hit \"refresh_token_reused\" on their next refresh.\n-        if self.provider == \"openai-codex\":\n-            try:\n-                _write_codex_cli_tokens(\n-                    updated.access_token,\n-                    updated.refresh_token,\n-                    last_refresh=updated.last_refresh,\n-                )\n-            except Exception as wexc:\n-                logger.debug(\"Failed to write refreshed Codex tokens to CLI file: %s\", wexc)\n         return updated\n \n     def _entry_needs_refresh(self, entry: PooledCredential) -> bool:","@@ -790,16 +698,6 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal\n                 if synced is not entry:\n                     entry = synced\n                     cleared_any = True\n-            # For openai-codex entries, sync from ~/.codex/auth.json before\n-            # any status/refresh checks.  This picks up tokens refreshed by\n-            # the Codex CLI or another Hermes profile.\n-            if (self.provider == \"openai-codex\"\n-                    and entry.last_status == STATUS_EXHAUSTED\n-                    and entry.refresh_token):\n-                synced = self._sync_codex_entry_from_cli(entry)\n-                if synced is not entry:\n-                    entry = synced\n-                    cleared_any = True\n             if entry.last_status == STATUS_EXHAUSTED:\n                 exhausted_until = _exhausted_until(entry)\n                 if exhausted_until is not None and now < exhausted_until:","@@ -1218,8 +1116,8 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup\n     elif provider == \"openai-codex\":\n         # Respect user suppression — `hermes auth remove openai-codex` marks\n         # the device_code source as suppressed so it won't be re-seeded from\n-        # either the Hermes auth store or ~/.codex/auth.json.  Without this\n-        # gate the removal is instantly undone on the next load_pool() call.\n+        # the Hermes auth store.  Without this gate the removal is instantly\n+        # undone on the next load_pool() call.\n         codex_suppressed = False\n         try:\n             from hermes_cli.auth import is_source_suppressed","@@ -1231,23 +1129,12 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup\n \n         state = _load_provider_state(auth_store, \"openai-codex\")\n         tokens = [redacted:secret-assignment]\"tokens\") if isinstance(state, dict) else None\n-        # Fallback: import from Codex CLI (~/.codex/auth.json) if Hermes auth\n-        # store has no tokens.  This mirrors resolve_codex_runtime_credentials()\n-        # so that load_pool() and list_authenticated_providers() detect tokens\n-        # that only exist in the Codex CLI shared file.\n-        if not (isinstance(tokens, dict) and tokens.get(\"access_token\")):\n-            try:\n-                from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens\n-                cli_tokens = [redacted:secret-assignment]\n-                if cli_tokens:\n-                    logger.info(\"Importing Codex CLI tokens into Hermes auth store.\")\n-                    _save_codex_tokens(cli_tokens)\n-                    # Re-read state after import\n-                    auth_store = _load_auth_store()\n-                    state = _load_provider_state(auth_store, \"openai-codex\")\n-                    tokens = [redacted:secret-assignment]\"tokens\") if isinstance(state, dict) else None\n-            except Exception as exc:\n-                logger.debug(\"Codex CLI token import failed: %s\", exc)\n+        # Hermes owns its own Codex auth state — we do NOT auto-import from\n+        # ~/.codex/auth.json at pool-load time.  OAuth refresh tokens are\n+        # single-use, so sharing them with Codex CLI / VS Code causes\n+        # refresh_token_reused race failures.  Users who want to adopt\n+        # existing Codex CLI credentials get a one-time, explicit prompt\n+        # via `hermes auth openai-codex`.\n         if isinstance(tokens, dict) and tokens.get(\"access_token\"):\n             active_sources.add(\"device_code\")\n             changed |= _upsert_entry("]},{"file":"hermes_cli/auth.py","hunks":["@@ -1434,49 +1434,6 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:\n     }\n \n \n-def _write_codex_cli_tokens(\n-    access_token: str,\n-    refresh_token: str,\n-    *,\n-    last_refresh: Optional[str] = None,\n-) -> None:\n-    \"\"\"Write refreshed tokens back to ~/.codex/auth.json.\n-\n-    OpenAI OAuth refresh tokens are single-use and rotate on every refresh.\n-    When Hermes refreshes a token it consumes the old refresh_token; if we\n-    don't write the new pair back, the Codex CLI (or VS Code extension) will\n-    fail with ``refresh_token_reused`` on its next refresh attempt.\n-\n-    This mirrors the Anthropic write-back to ~/.claude/.credentials.json\n-    via ``_write_claude_code_credentials()``.\n-    \"\"\"\n-    codex_home = os.getenv(\"CODEX_HOME\", \"\").strip()\n-    if not codex_home:\n-        codex_home = str(Path.home() / \".codex\")\n-    auth_path = Path(codex_home).expanduser() / \"auth.json\"\n-    try:\n-        existing: Dict[str, Any] = {}\n-        if auth_path.is_file():\n-            existing = json.loads(auth_path.read_text(encoding=\"utf-8\"))\n-        if not isinstance(existing, dict):\n-            existing = {}\n-\n-        tokens_dict = [redacted:secret-assignment]\"tokens\")\n-        if not isinstance(tokens_dict, dict):\n-            tokens_dict = {}\n-        tokens_dict[\"access_token\"] = access_token\n-        tokens_dict[\"refresh_token\"] = refresh_token\n-        existing[\"tokens\"] = tokens_dict\n-        if last_refresh is not None:\n-            existing[\"last_refresh\"] = last_refresh\n-\n-        auth_path.parent.mkdir(parents=True, exist_ok=True)\n-        auth_path.write_text(json.dumps(existing, indent=2), encoding=\"utf-8\")\n-        auth_path.chmod(0o600)\n-    except (OSError, IOError) as exc:\n-        logger.debug(\"Failed to write refreshed tokens to %s: %s\", auth_path, exc)\n-\n-\n def _save_codex_tokens(tokens: [redacted:secret-assignment] str], last_refresh: str = None) -> None:\n     \"\"\"Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json).\"\"\"\n     if last_refresh is None:","@@ -1604,12 +1561,6 @@ def _refresh_codex_auth_tokens(\n     updated_tokens[\"refresh_token\"] = refreshed[\"refresh_token\"]\n \n     _save_codex_tokens(updated_tokens)\n-    # Write back to ~/.codex/auth.json so Codex CLI / VS Code stay in sync.\n-    _write_codex_cli_tokens(\n-        refreshed[\"access_token\"],\n-        refreshed[\"refresh_token\"],\n-        last_refresh=refreshed.get(\"last_refresh\"),\n-    )\n     return updated_tokens\n \n ","@@ -1654,25 +1605,7 @@ def resolve_codex_runtime_credentials(\n     refresh_skew_seconds: int = CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,\n ) -> Dict[str, Any]:\n     \"\"\"Resolve runtime credentials from Hermes's own Codex token store.\"\"\"\n-    try:\n-        data = _read_codex_tokens()\n-    except AuthError as orig_err:\n-        # Only attempt migration when there are NO tokens stored at all\n-        # (code == \"codex_auth_missing\"), not when tokens exist but are invalid.\n-        if orig_err.code != \"codex_auth_missing\":\n-            raise\n-\n-        # Migration: user had Codex as active provider with old storage (~/.codex/).\n-        cli_tokens = [redacted:secret-assignment]\n-        if cli_tokens:\n-            logger.info(\"Migrating Codex credentials from ~/.codex/ to Hermes auth store\")\n-            print(\"⚠️  Migrating Codex credentials to Hermes's own auth store.\")\n-            print(\"   This avoids conflicts with Codex CLI and VS Code.\")\n-            print(\"   Run `hermes auth` to create a fully independent session.\\n\")\n-            _save_codex_tokens(cli_tokens)\n-            data = _read_codex_tokens()\n-        else:\n-            raise\n+    data = _read_codex_tokens()\n     tokens = [redacted:secret-assignment]\"tokens\"])\n     access_token = [redacted:secret-assignment]\"access_token\", \"\") or \"\").strip()\n     refresh_timeout_seconds = float(os.getenv(\"HERMES_CODEX_REFRESH_TIMEOUT_SECONDS\", \"20\"))"]},{"file":"tests/hermes_cli/test_auth_codex_provider.py","hunks":["@@ -14,7 +14,6 @@\n     PROVIDER_REGISTRY,\n     _read_codex_tokens,\n     _save_codex_tokens,\n-    _write_codex_cli_tokens,\n     _import_codex_cli_tokens,\n     get_codex_auth_status,\n     get_provider_auth_state,","@@ -182,98 +181,6 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch):\n     assert data[\"tokens\"][\"access_token\"] == \"hermes-at\"\n \n \n-def test_write_codex_cli_tokens_creates_file(tmp_path, monkeypatch):\n-    \"\"\"_write_codex_cli_tokens creates ~/.codex/auth.json with refreshed tokens.\"\"\"\n-    codex_home = tmp_path / \"codex-cli\"\n-    monkeypatch.setenv(\"CODEX_HOME\", str(codex_home))\n-\n-    _write_codex_cli_tokens(\"new-access\", \"new-refresh\", last_refresh=\"2026-04-12T00:00:00Z\")\n-\n-    auth_path = codex_home / \"auth.json\"\n-    assert auth_path.exists()\n-    data = json.loads(auth_path.read_text())\n-    assert data[\"tokens\"][\"access_token\"] == \"new-access\"\n-    assert data[\"tokens\"][\"refresh_token\"] == \"new-refresh\"\n-    assert data[\"last_refresh\"] == \"2026-04-12T00:00:00Z\"\n-    # Verify file permissions are restricted\n-    assert (auth_path.stat().st_mode & 0o777) == 0o600\n-\n-\n-def test_write_codex_cli_tokens_preserves_existing(tmp_path, monkeypatch):\n-    \"\"\"_write_codex_cli_tokens preserves extra fields in existing auth.json.\"\"\"\n-    codex_home = tmp_path / \"codex-cli\"\n-    codex_home.mkdir(parents=True, exist_ok=True)\n-    monkeypatch.setenv(\"CODEX_HOME\", str(codex_home))\n-\n-    existing = {\n-        \"tokens\": {\n-            \"access_token\": \"old-access\",\n-            \"refresh_token\": \"old-refresh\",\n-            \"extra_field\": \"preserved\",\n-        },\n-        \"last_refresh\": \"2026-01-01T00:00:00Z\",\n-        \"custom_key\": \"keep_me\",\n-    }\n-    (codex_home / \"auth.json\").write_text(json.dumps(existing))\n-\n-    _write_codex_cli_tokens(\"updated-access\", \"updated-refresh\")\n-\n-    data = json.loads((codex_home / \"auth.json\").read_text())\n-    assert data[\"tokens\"][\"access_token\"] == \"updated-access\"\n-    assert data[\"tokens\"][\"refresh_token\"] == \"updated-refresh\"\n-    assert data[\"tokens\"][\"extra_field\"] == \"preserved\"\n-    assert data[\"custom_key\"] == \"keep_me\"\n-    # last_refresh not updated since we didn't pass it\n-    assert data[\"last_refresh\"] == \"2026-01-01T00:00:00Z\"\n-\n-\n-def test_write_codex_cli_tokens_handles_missing_dir(tmp_path, monkeypatch):\n-    \"\"\"_write_codex_cli_tokens creates parent directories if missing.\"\"\"\n-    codex_home = tmp_path / \"does\" / \"not\" / \"exist\"\n-    monkeypatch.setenv(\"CODEX_HOME\", str(codex_home))\n-\n-    _write_codex_cli_tokens(\"at\", \"rt\")\n-\n-    assert (codex_home / \"auth.json\").exists()\n-    data = json.loads((codex_home / \"auth.json\").read_text())\n-    assert data[\"tokens\"][\"access_token\"] == \"at\"\n-\n-\n-def test_refresh_codex_auth_tokens_writes_back_to_cli(tmp_path, monkeypatch):\n-    \"\"\"After refreshing, _refresh_codex_auth_tokens writes back to ~/.codex/auth.json.\"\"\"\n-    from hermes_cli.auth import _refresh_codex_auth_tokens\n-\n-    hermes_home = tmp_path / \"hermes\"\n-    codex_home = tmp_path / \"codex-cli\"\n-    hermes_home.mkdir(parents=True, exist_ok=True)\n-    codex_home.mkdir(parents=True, exist_ok=True)\n-    (hermes_home / \"auth.json\").write_text(json.dumps({\"version\": 1, \"providers\": {}}))\n-    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n-    monkeypatch.setenv(\"CODEX_HOME\", str(codex_home))\n-\n-    # Write initial CLI tokens\n-    (codex_home / \"auth.json\").write_text(json.dumps({\n-        \"tokens\": {\"access_token\": \"old-at\", \"refresh_token\": \"old-rt\"},\n-    }))\n-\n-    # Mock the pure refresh to return new tokens\n-    monkeypatch.setattr(\"hermes_cli.auth.refresh_codex_oauth_pure\", lambda *a, **kw: {\n-        \"access_token\": \"refreshed-at\",\n-        \"refresh_token\": \"refreshed-rt\",\n-        \"last_refresh\": \"2026-04-12T01:00:00Z\",\n-    })\n-\n-    _refresh_codex_auth_tokens(\n-        {\"access_token\": \"old-at\", \"refresh_token\": \"old-rt\"},\n-        timeout_seconds=10,\n-    )\n-\n-    # Verify CLI file was updated\n-    cli_data = json.loads((codex_home / \"auth.json\").read_text())\n-    assert cli_data[\"tokens\"][\"access_token\"] == \"refreshed-at\"\n-    assert cli_data[\"tokens\"][\"refresh_token\"] == \"refreshed-rt\"\n-\n-\n def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch):\n     hermes_home = tmp_path / \"hermes\"\n     _setup_hermes_auth(hermes_home)\n"]}]},{"kind":"pr","number":13330,"title":"fix(auth): suppress codex CLI reseed on remove","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b02833f32d","number":0},{"kind":"pr","value":"#12747","number":12747},{"kind":"pr","value":"#12360","number":12360},{"kind":"pr","value":"#12360","number":12360},{"kind":"pr","value":"#12747","number":12747}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":13989,"title":"fix(auth_commands): normalise manual: prefix before singleton comparisons","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"2c69b3eca","number":0},{"kind":"commit","value":"2c69b3eca8187013223677985c013e714e5c1d70","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"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","implication":"superseded"},"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\": \"sk-or-\" + \"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\": \"sk-or-\" + \"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\": \"sk-or-\" + \"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\": \"sk-ant-\" + \"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}}