{"repo":"NousResearch/hermes-agent","public_id":"32849","display_label":"Treat revoked or invalidated OAuth credentials as terminal in pool","summary":"These members all concern the credential pool's handling of token_invalidated and token_revoked OAuth failures, with one issue and several PRs proposing they be marked permanently dead (e.g., a STATUS_DEAD state) rather than given a 1-hour cooldown. The titles show a shared theme, but they do not establish what has been merged or changed.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34412,"merged_at":"2026-05-29T06:45:42+00:00"},"resolution":{"state":"resolved","by":"pr-34412"},"freshness":{"state":"verified","last_verified_at":"2026-05-29T06:45:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":32849,"pr_number":34412,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-29T06:45:42+00:00"}],"members":[{"kind":"issue","number":32849,"title":"Credential pool: token_invalidated / token_revoked should be terminal failures, not 1-hour cooldowns","url":null,"state":"closed"},{"kind":"pr","number":32873,"title":"fix(credential-pool): treat token_invalidated/token_revoked as terminal","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":33904,"title":"feat(credential-pool): mark revoked/invalidated credentials as permanently dead","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"86a389fee29796a079599d479229a68f5e845671","number":0},{"kind":"pr","value":"#34412","number":34412},{"kind":"pr","value":"#32849","number":32849}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":34412,"title":"fix(credential-pool): STATUS_DEAD for terminal OAuth failures","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/credential_pool.py","tests/agent/test_credential_pool.py"],"fix_hunks":[{"file":"agent/credential_pool.py","hunks":["@@ -55,6 +55,38 @@ def _load_config_safe() -> Optional[dict]:\n \n STATUS_OK = \"ok\"\n STATUS_EXHAUSTED = \"exhausted\"\n+# Terminal failure — the credential will never recover on its own.  Used for\n+# upstream-permanent OAuth states like ``token_invalidated`` / ``token_revoked``\n+# where retrying after a TTL cooldown is guaranteed to fail.  ``DEAD`` entries\n+# are excluded from rotation unconditionally and only clear when an explicit\n+# write-side sync (e.g. ``_save_codex_tokens`` after a fresh device-code\n+# login) rewrites the tokens.\n+STATUS_DEAD = \"dead\"\n+\n+# OAuth error reasons that indicate the credential is permanently invalid\n+# server-side and cannot be recovered by retry/refresh.  Sourced from\n+# OpenAI Codex Responses API, Anthropic, xAI, and Google OAuth spec.\n+_TERMINAL_AUTH_REASONS = frozenset({\n+    \"token_invalidated\",   # OpenAI Codex: \"Your authentication token has been invalidated.\"\n+    \"token_revoked\",        # OAuth 2.0 RFC 7009: token explicitly revoked\n+    \"invalid_token\",        # RFC 6750: bearer token is malformed/expired/revoked\n+    \"invalid_grant\",        # RFC 6749: refresh_token rejected during refresh\n+    \"unauthorized_client\",  # RFC 6749: client no longer authorized\n+    \"refresh_token_reused\", # Single-use refresh token consumed by another process\n+})\n+\n+# How long a DEAD manual credential is preserved before being pruned.\n+# Manual entries (``manual:*``) are independent credentials with no singleton\n+# to re-seed from, so pruning them after a quiet window cleans up dead state\n+# without losing recoverability — the user always has the option to re-add\n+# via ``hermes auth add``.\n+#\n+# Singleton-seeded entries (``device_code``, ``loopback_pkce``, ``claude_code``)\n+# are NOT pruned because ``_seed_from_singletons`` would just re-create them\n+# on the next ``load_pool()`` with the same stale singleton tokens, defeating\n+# the cleanup.  They remain in the pool marked DEAD until an explicit re-auth\n+# write-side sync (``_save_codex_tokens`` etc.) clears the status.\n+DEAD_MANUAL_PRUNE_TTL_SECONDS = 24 * 60 * 60  # 24 hours\n \n AUTH_TYPE_OAUTH = \"oauth\"\n AUTH_TYPE_API_KEY = \"[redacted:secret-assignment]\"","@@ -438,6 +470,29 @@ def _persist(self) -> None:\n             [entry.to_dict() for entry in self._entries],\n         )\n \n+    def _is_terminal_auth_failure(\n+        self,\n+        status_code: Optional[int],\n+        normalized_error: Dict[str, Any],\n+    ) -> bool:\n+        \"\"\"Detect upstream-permanent OAuth failures that won't recover on TTL.\n+\n+        Only fires for 401 responses whose error code/reason matches a known\n+        terminal OAuth state (token_invalidated, token_revoked, invalid_grant,\n+        etc.).  Distinguishes permanent failures from transient ones like\n+        token_expired (refreshable) or generic 401 without a specific reason\n+        (could be a server-side glitch worth retrying).\n+\n+        Returns False for non-401 status codes — 429 rate limits and 402\n+        billing failures are transient by nature and should keep TTL semantics.\n+        \"\"\"\n+        if status_code != 401:\n+            return False\n+        reason = normalized_error.get(\"reason\")\n+        if not isinstance(reason, str):\n+            return False\n+        return reason.strip().lower() in _TERMINAL_AUTH_REASONS\n+\n     def _mark_exhausted(\n         self,\n         entry: PooledCredential,","@@ -445,9 +500,20 @@ def _mark_exhausted(\n         error_context: Optional[Dict[str, Any]] = None,\n     ) -> PooledCredential:\n         [redacted:secret-assignment] = _normalize_error_context(error_context)\n+        # Permanent OAuth failures (token_invalidated, token_revoked, etc.)\n+        # transition to STATUS_DEAD instead of STATUS_EXHAUSTED.  Without this,\n+        # a revoked credential gets a 1-hour TTL cooldown and then re-enters\n+        # rotation, failing immediately every hour until the user manually\n+        # removes it (issue #32849).  DEAD entries are excluded from rotation\n+        # unconditionally and only clear via an explicit re-auth write-side\n+        # sync (``_save_codex_tokens`` after a fresh device-code login).\n+        if self._is_terminal_auth_failure(status_code, normalized_error):\n+            terminal_status = STATUS_DEAD\n+        else:\n+            terminal_status = STATUS_EXHAUSTED\n         updated = replace(\n             entry,\n-            last_status=STATUS_EXHAUSTED,\n+            last_status=terminal_status,\n             last_status_at=time.time(),\n             last_error_code=status_code,\n             last_error_reason=normalized_error.get(\"reason\"),","@@ -1158,13 +1224,14 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal\n         \"\"\"\n         now = time.time()\n         cleared_any = False\n+        entries_to_prune: List[str] = []\n         available: List[PooledCredential] = []\n         for entry in self._entries:\n             # For anthropic claude_code entries, sync from the credentials file\n             # before any status/refresh checks. This picks up tokens refreshed\n             # by other processes (Claude Code CLI, other Hermes profiles).\n             if (self.provider == \"anthropic\" and entry.source == \"claude_code\"\n-                    and entry.last_status == STATUS_EXHAUSTED):\n+                    and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):\n                 synced = self._sync_anthropic_entry_from_credentials_file(entry)\n                 if synced is not entry:\n                     entry = synced","@@ -1175,7 +1242,7 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal\n             # exhausted status stale.\n             if (self.provider == \"nous\"\n                     and entry.source == \"device_code\"\n-                    and entry.last_status == STATUS_EXHAUSTED):\n+                    and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):\n                 synced = self._sync_nous_entry_from_auth_store(entry)\n                 if synced is not entry:\n                     entry = synced","@@ -1187,7 +1254,7 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal\n             # future for ChatGPT weekly windows).\n             if (self.provider == \"openai-codex\"\n                     and entry.source == \"device_code\"\n-                    and entry.last_status == STATUS_EXHAUSTED):\n+                    and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):\n                 synced = self._sync_codex_entry_from_auth_store(entry)\n                 if synced is not entry:\n                     entry = synced","@@ -1198,11 +1265,41 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal\n             # xAI Grok OAuth login) has since rotated in auth.json.\n             if (self.provider == \"xai-oauth\"\n                     and entry.source == \"loopback_pkce\"\n-                    and entry.last_status == STATUS_EXHAUSTED):\n+                    and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):\n                 synced = self._sync_xai_oauth_entry_from_auth_store(entry)\n                 if synced is not entry:\n                     entry = synced\n                     cleared_any = True\n+            if entry.last_status == STATUS_DEAD:\n+                # Manual DEAD credentials get pruned after a 24h quiet window\n+                # so the pool doesn't accumulate dead entries forever.  The\n+                # user can always re-add via ``hermes auth add``.  Singleton-\n+                # seeded DEAD entries are kept so the audit trail (label,\n+                # last_error_reason, timestamps) stays visible — pruning them\n+                # would just be undone by ``_seed_from_singletons`` on the\n+                # next load anyway.\n+                if _is_manual_source(entry.source):\n+                    dead_at = entry.last_status_at or 0\n+                    if dead_at and now - dead_at > DEAD_MANUAL_PRUNE_TTL_SECONDS:\n+                        _label = entry.label or entry.id[:8]\n+                        logger.warning(\n+                            \"credential pool: pruning DEAD manual entry %s \"\n+                            \"(reason=%s, age=%.1fh) — re-add via `hermes auth add %s`\",\n+                            _label,\n+                            entry.last_error_reason or \"unknown\",\n+                            (now - dead_at) / 3600.0,\n+                            self.provider,\n+                        )\n+                        # Mark for removal after the loop completes; we can't\n+                        # mutate self._entries while iterating.\n+                        entries_to_prune.append(entry.id)\n+                        cleared_any = True\n+                # Permanently failed credentials never re-enter rotation via\n+                # TTL.  They only clear when a write-side re-auth sync rewrites\n+                # the tokens (e.g. ``_save_codex_tokens`` after a fresh\n+                # device-code login).  The auth.json-sync paths below handle\n+                # the re-auth case for OAuth singletons.\n+                continue\n             if entry.last_status == STATUS_EXHAUSTED:\n                 exhausted_until = _exhausted_until(entry)\n                 if exhausted_until is not None and now < exhausted_until:","@@ -1226,6 +1323,9 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal\n                     continue\n                 entry = refreshed\n             available.append(entry)\n+        if entries_to_prune:\n+            pruned_ids = set(entries_to_prune)\n+            self._entries = [e for e in self._entries if e.id not in pruned_ids]\n         if cleared_any:\n             self._persist()\n         return available","@@ -1293,11 +1393,22 @@ def mark_exhausted_and_rotate(\n             if entry is None:\n                 return None\n             _label = entry.label or entry.id[:8]\n-            logger.info(\n-                \"credential pool: marking %s exhausted (status=%s), rotating\",\n-                _label, status_code,\n-            )\n             self._mark_exhausted(entry, status_code, error_context)\n+            # Re-read the updated entry to log the correct terminal state.\n+            updated_entry = next(\n+                (e for e in self._entries if e.id == entry.id), entry,\n+            )\n+            if updated_entry.last_status == STATUS_DEAD:\n+                logger.warning(\n+                    \"credential pool: marking %s DEAD (status=%s, reason=%s) — \"\n+                    \"permanently failed, will NOT re-enter rotation until re-auth\",\n+                    _label, status_code, updated_entry.last_error_reason or \"unknown\",\n+                )\n+            else:\n+                logger.info(\n+                    \"credential pool: marking %s exhausted (status=%s), rotating\",\n+                    _label, status_code,\n+                )\n             self._current_id = None\n             next_entry = self._select_unlocked()\n             if next_entry:"]},{"file":"tests/agent/test_credential_pool.py","hunks":["@@ -379,6 +379,415 @@ def test_mark_exhausted_and_rotate_persists_status(tmp_path, monkeypatch):\n     assert persisted[\"last_error_code\"] == 402\n \n \n+def test_token_invalidated_marks_credential_dead(tmp_path, monkeypatch):\n+    \"\"\"OpenAI Codex token_invalidated must mark the credential DEAD, not exhausted.\n+\n+    Regression for #32849: when an OAuth credential is revoked upstream, the\n+    1-hour exhausted TTL means it re-enters rotation every hour and fails\n+    again with the same 401 — surfacing as \"Failed to generate context\n+    summary\" on context compression.  Terminal OAuth failures should never\n+    auto-recover.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    _write_auth_store(\n+        tmp_path,\n+        {\n+            \"version\": 1,\n+            \"credential_pool\": {\n+                \"openai-codex\": [\n+                    {\n+                        \"id\": \"cred-dead\",\n+                        \"label\": \"revoked\",\n+                        \"auth_type\": \"oauth\",\n+                        \"priority\": 0,\n+                        \"source\": \"manual:device_code\",\n+                        \"access_token\": \"[redacted:secret-assignment]\",\n+                        \"refresh_token\": \"[redacted:secret-assignment]\",\n+                    },\n+                    {\n+                        \"id\": \"cred-ok\",\n+                        \"label\": \"healthy\",\n+                        \"auth_type\": \"oauth\",\n+                        \"priority\": 1,\n+                        \"source\": \"manual:device_code\",\n+                        \"access_token\": \"[redacted:secret-assignment]\",\n+                        \"refresh_token\": \"[redacted:secret-assignment]\",\n+                    },\n+                ]\n+            },\n+        },\n+    )\n+\n+    from agent.credential_pool import load_pool, STATUS_DEAD\n+\n+    pool = load_pool(\"openai-codex\")\n+    assert pool.select().id == \"cred-dead\"\n+\n+    # Simulate the exact OpenAI Codex 401 token_invalidated response shape.\n+    next_entry = pool.mark_exhausted_and_rotate(\n+        status_code=401,\n+        error_context={\n+            \"reason\": \"token_invalidated\",\n+            \"message\": \"Your authentication token has been invalidated. Please try signing in again.\",\n+        },\n+    )\n+\n+    # Rotation still works — we hand off to the healthy credential.\n+    assert next_entry is not None\n+    assert next_entry.id == \"cred-ok\"\n+\n+    # The revoked credential is now permanently marked DEAD.\n+    auth_payload = json.loads((tmp_path / \"hermes\" / \"auth.json\").read_text())\n+    persisted = auth_payload[\"credential_pool\"][\"openai-codex\"][0]\n+    assert persisted[\"last_status\"] == STATUS_DEAD\n+    assert persisted[\"last_error_code\"] == 401\n+    assert persisted[\"last_error_reason\"] == \"token_invalidated\"\n+\n+\n+def test_dead_credential_never_re_enters_rotation_after_ttl(tmp_path, monkeypatch):\n+    \"\"\"A DEAD credential must stay excluded regardless of how much time passes.\n+\n+    The exhausted TTL clears entries after 5 min (401) / 1 hour (429).\n+    A DEAD credential has no recovery TTL — it stays dead until either\n+    (a) an explicit re-auth write-side sync rewrites the tokens, or\n+    (b) the manual-prune TTL elapses (covered by separate tests below).\n+    This test verifies the core invariant in the recent-entry window.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    # DEAD entry from 2 hours ago — well past the exhausted TTLs (5min/1h)\n+    # but well within the 24h manual-prune window.\n+    two_hours_ago = time.time() - (2 * 3600)\n+    _write_auth_store(\n+        tmp_path,\n+        {\n+            \"version\": 1,\n+            \"credential_pool\": {\n+                \"openai-codex\": [\n+                    {\n+                        \"id\": \"cred-dead\",\n+                        \"label\": \"revoked\",\n+                        \"auth_type\": \"oauth\",\n+                        \"priority\": 0,\n+                        \"source\": \"manual:device_code\",\n+                        \"access_token\": \"[redacted:secret-assignment]\",\n+                        \"refresh_token\": \"[redacted:secret-assignment]\",\n+                        \"last_status\": \"dead\",\n+                        \"last_status_at\": two_hours_ago,\n+                        \"last_error_code\": 401,\n+                        \"last_error_reason\": \"token_invalidated\",\n+                    },\n+                    {\n+                        \"id\": \"cred-ok\",\n+                        \"label\": \"healthy\",\n+                        \"auth_type\": \"oauth\",\n+                        \"priority\": 1,\n+                        \"source\": \"manual:device_code\",\n+                        \"access_token\": \"[redacted:secret-assignment]\",\n+                        \"refresh_token\": \"[redacted:secret-assignment]\",\n+                    },\n+                ]\n+            },\n+        },\n+    )\n+\n+    from agent.credential_pool import load_pool, STATUS_DEAD\n+\n+    pool = load_pool(\"openai-codex\")\n+    selected = pool.select()\n+    # Should skip the dead entry and pick the healthy one — even though\n+    # the dead entry has priority 0 (would normally be picked first) and\n+    # plenty of time has passed since it was marked dead.\n+    assert selected is not None\n+    assert selected.id == \"cred-ok\"\n+\n+    # The DEAD entry is still marked dead on disk — not cleared by TTL.\n+    auth_payload = json.loads((tmp_path / \"hermes\" / \"auth.json\").read_text())\n+    dead_entry = next(e for e in auth_payload[\"credential_pool\"][\"openai-codex\"]\n+                       if e[\"id\"] == \"cred-dead\")\n+    assert dead_entry[\"last_status\"] == STATUS_DEAD\n+\n+\n+def test_429_rate_limit_still_uses_exhausted_not_dead(tmp_path, monkeypatch):\n+    \"\"\"429 rate limits must NOT be treated as terminal.\n+\n+    They should keep the existing 1-hour TTL cooldown semantics so the\n+    credential re-enters rotation once the rate window resets.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    _write_auth_store(\n+        tmp_path,\n+        {\n+            \"version\": 1,\n+            \"credential_pool\": {\n+                \"openai-codex\": [\n+                    {\n+                        \"id\": \"cred-1\",\n+                        \"label\": \"primary\",\n+                        \"auth_type\": \"oauth\",\n+                        \"priority\": 0,\n+                        \"source\": \"manual:device_code\",\n+                        \"access_token\": \"at-1\",\n+                        \"refresh_token\": \"rt-1\",\n+                    },\n+                    {\n+                        \"id\": \"cred-2\",\n+                        \"label\": \"secondary\",\n+                        \"auth_type\": \"oauth\",\n+                        \"priority\": 1,\n+                        \"source\": \"manual:device_code\",\n+                        \"access_token\": \"at-2\",\n+                        \"refresh_token\": \"rt-2\",\n+                    },\n+                ]\n+            },\n+        },\n+    )\n+\n+    from agent.credential_pool import load_pool, STATUS_EXHAUSTED\n+\n+    pool = load_pool(\"openai-codex\")\n+    assert pool.select().id == \"cred-1\"\n+\n+    next_entry = pool.mark_exhausted_and_rotate(\n+        status_code=429,\n+        error_context={\"reason\": \"rate_limit_exceeded\", \"message\": \"Rate limit exceeded\"},\n+    )\n+    assert next_entry is not None\n+    assert next_entry.id == \"cred-2\"\n+\n+    auth_payload = json.loads((tmp_path / \"hermes\" / \"auth.json\").read_text())\n+    persisted = auth_payload[\"credential_pool\"][\"openai-codex\"][0]\n+    # 429 stays exhausted (transient) — NOT dead.\n+    assert persisted[\"last_status\"] == STATUS_EXHAUSTED\n+    assert persisted[\"last_error_code\"] == 429\n+\n+\n+def test_generic_401_without_terminal_reason_still_uses_exhausted(tmp_path, monkeypatch):\n+    \"\"\"A 401 with no specific code/reason should keep TTL semantics.\n+\n+    Only specific terminal reasons (token_invalidated, token_revoked, etc.)\n+    transition to DEAD.  A generic 401 might be a transient server-side\n+    issue worth retrying after the 5-min TTL.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    _write_auth_store(\n+        tmp_path,\n+        {\n+            \"version\": 1,\n+            \"credential_pool\": {\n+                \"openai-codex\": [\n+                    {\n+                        \"id\": \"cred-1\",\n+                        \"label\": \"primary\",\n+                        \"auth_type\": \"oauth\",\n+                        \"priority\": 0,\n+                        \"source\": \"manual:device_code\",\n+                        \"access_token\": \"at-1\",\n+                        \"refresh_token\": \"rt-1\",\n+                    },\n+                    {\n+                        \"id\": \"cred-2\",\n+                        \"label\": \"secondary\",\n+                        \"auth_type\": \"oauth\",\n+                        \"priority\": 1,\n+                        \"source\": \"manual:device_code\",\n+                        \"access_token\": \"at-2\",\n+                        \"refresh_token\": \"rt-2\",\n+                    },\n+                ]\n+            },\n+        },\n+    )\n+\n+    from agent.credential_pool import load_pool, STATUS_EXHAUSTED\n+\n+    pool = load_pool(\"openai-codex\")\n+    pool.select()\n+\n+    # 401 with no specific reason — stays exhausted, NOT dead.\n+    pool.mark_exhausted_and_rotate(\n+        status_code=401,\n+        error_context={\"message\": \"Unauthorized\"},\n+    )\n+\n+    auth_payload = json.loads((tmp_path / \"hermes\" / \"auth.json\").read_text())\n+    persisted = auth_payload[\"credential_pool\"][\"openai-codex\"][0]\n+    assert persisted[\"last_status\"] == STATUS_EXHAUSTED\n+    assert persisted[\"last_error_code\"] == 401\n+\n+\n+def test_dead_manual_entry_pruned_after_24h(tmp_path, monkeypatch):\n+    \"\"\"A DEAD manual entry is removed from the pool after the prune TTL.\n+\n+    Manual entries (``manual:*``) are independent credentials with no\n+    singleton to re-seed from, so we can clean them up after a quiet\n+    window without losing recoverability — the user can always re-add\n+    via ``hermes auth add``.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    # DEAD entry from > 24h ago\n+    long_ago = time.time() - (25 * 3600)\n+    _write_auth_store(\n+        tmp_path,\n+        {\n+            \"version\": 1,\n+            \"credential_pool\": {\n+                \"openai-codex\": [\n+                    {\n+                        \"id\": \"cred-old-dead\",\n+                        \"label\": \"ancient-dead\",\n+                        \"auth_type\": \"oauth\",\n+                        \"priority\": 0,\n+                        \"source\": \"manual:device_code\",\n+                        \"access_token\": \"stale\",\n+                        \"refresh_token\": \"stale\",\n+                        \"last_status\": \"dead\",\n+                        \"last_status_at\": long_ago,\n+                        \"last_error_code\": 401,\n+                        \"last_error_reason\": \"token_invalidated\",\n+                    },\n+                    {\n+                        \"id\": \"cred-ok\",\n+                        \"label\": \"healthy\",\n+                        \"auth_type\": \"oauth\",\n+                        \"priority\": 1,\n+                        \"source\": \"manual:device_code\",\n+                        \"access_token\": \"[redacted:secret-assignment]\",\n+                        \"refresh_token\": \"[redacted:secret-assignment]\",\n+                    },\n+                ]\n+            },\n+        },\n+    )\n+\n+    from agent.credential_pool import load_pool\n+\n+    pool = load_pool(\"openai-codex\")\n+    # Trigger _available_entries via select; that runs the prune.\n+    selected = pool.select()\n+    assert selected is not None\n+    assert selected.id == \"cred-ok\"\n+\n+    # On-disk pool should have the dead entry removed.\n+    auth_payload = json.loads((tmp_path / \"hermes\" / \"auth.json\").read_text())\n+    persisted = auth_payload[\"credential_pool\"][\"openai-codex\"]\n+    assert len(persisted) == 1\n+    assert persisted[0][\"id\"] == \"cred-ok\"\n+\n+\n+def test_dead_manual_entry_kept_within_24h(tmp_path, monkeypatch):\n+    \"\"\"A DEAD manual entry stays in the pool until the prune TTL elapses.\n+\n+    Recent DEAD entries are kept so the audit trail (last_error_reason,\n+    timestamps) remains visible while the user investigates.  They simply\n+    don't participate in rotation (covered by the DEAD-skip test above).\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    # DEAD entry from only an hour ago — well within the 24h window\n+    recent = time.time() - 3600\n+    _write_auth_store(\n+        tmp_path,\n+        {\n+            \"version\": 1,\n+            \"credential_pool\": {\n+                \"openai-codex\": [\n+                    {\n+                        \"id\": \"cred-recent-dead\",\n+                        \"label\": \"recent-dead\",\n+                        \"auth_type\": \"oauth\",\n+                        \"priority\": 0,\n+                        \"source\": \"manual:device_code\",\n+                        \"access_token\": \"stale\",\n+                        \"refresh_token\": \"stale\",\n+                        \"last_status\": \"dead\",\n+                        \"last_status_at\": recent,\n+                        \"last_error_code\": 401,\n+                        \"last_error_reason\": \"token_invalidated\",\n+                    },\n+                    {\n+                        \"id\": \"cred-ok\",\n+                        \"label\": \"healthy\",\n+                        \"auth_type\": \"oauth\",\n+                        \"priority\": 1,\n+                        \"source\": \"manual:device_code\",\n+                        \"access_token\": \"[redacted:secret-assignment]\",\n+                        \"refresh_token\": \"[redacted:secret-assignment]\",\n+                    },\n+                ]\n+            },\n+        },\n+    )\n+\n+    from agent.credential_pool import load_pool, STATUS_DEAD\n+\n+    pool = load_pool(\"openai-codex\")\n+    selected = pool.select()\n+    assert selected is not None\n+    assert selected.id == \"cred-ok\"\n+\n+    # On-disk pool should still have BOTH entries — recent dead is preserved.\n+    auth_payload = json.loads((tmp_path / \"hermes\" / \"auth.json\").read_text())\n+    persisted = auth_payload[\"credential_pool\"][\"openai-codex\"]\n+    assert len(persisted) == 2\n+    dead_entry = next(e for e in persisted if e[\"id\"] == \"cred-recent-dead\")\n+    assert dead_entry[\"last_status\"] == STATUS_DEAD\n+\n+\n+def test_dead_singleton_seeded_entry_not_pruned(tmp_path, monkeypatch):\n+    \"\"\"A DEAD ``device_code`` entry must NOT be pruned even after 24h.\n+\n+    Singleton-seeded entries get re-created by ``_seed_from_singletons`` on\n+    every ``load_pool()``, so pruning them is pointless — they reappear\n+    immediately with the same stale singleton tokens.  Keep them visible\n+    with the DEAD marker so the user knows what's broken.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n+    long_ago = time.time() - (48 * 3600)\n+    _write_auth_store(\n+        tmp_path,\n+        {\n+            \"version\": 1,\n+            \"providers\": {\n+                \"openai-codex\": {\n+                    \"tokens\": {\"access_token\": \"[redacted:secret-assignment]\", \"refresh_token\": \"[redacted:secret-assignment]\"},\n+                    \"last_refresh\": \"2026-01-01T00:00:00Z\",\n+                    \"auth_mode\": \"chatgpt\",\n+                },\n+            },\n+            \"credential_pool\": {\n+                \"openai-codex\": [\n+                    {\n+                        \"id\": \"cred-seeded-dead\",\n+                        \"label\": \"seeded-dead\",\n+                        \"auth_type\": \"oauth\",\n+                        \"priority\": 0,\n+                        \"source\": \"device_code\",   # singleton-seeded, NOT manual\n+                        \"access_token\": \"[redacted:secret-assignment]\",\n+                        \"refresh_token\": \"[redacted:secret-assignment]\",\n+                        \"last_status\": \"dead\",\n+                        \"last_status_at\": long_ago,\n+                        \"last_error_code\": 401,\n+                        \"last_error_reason\": \"token_invalidated\",\n+                    },\n+                ]\n+            },\n+        },\n+    )\n+\n+    from agent.credential_pool import load_pool, STATUS_DEAD\n+\n+    pool = load_pool(\"openai-codex\")\n+    # No healthy entry available; select returns None (pool empty for rotation).\n+    assert pool.select() is None\n+\n+    # On-disk: the singleton-seeded DEAD entry is preserved.\n+    auth_payload = json.loads((tmp_path / \"hermes\" / \"auth.json\").read_text())\n+    persisted = auth_payload[\"credential_pool\"][\"openai-codex\"]\n+    assert len(persisted) == 1\n+    assert persisted[0][\"id\"] == \"cred-seeded-dead\"\n+    assert persisted[0][\"last_status\"] == STATUS_DEAD\n+\n+\n def test_load_pool_seeds_env_api_key(tmp_path, monkeypatch):\n     monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \"hermes\"))\n     monkeypatch.setenv(\"OPENROUTER_API_KEY\", \"sk-or-seeded\")\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}