{"repo":"NousResearch/hermes-agent","public_id":"33538","display_label":"Codex OAuth credential pool sync and lifecycle fixes","summary":"These issues and pull requests address stale credentials, pool key mismatches, auth.json synchronization, and race conditions in the Codex OAuth authentication flow. Several entries also touch re-authorization paths and persistence alignment for device-code and manual auth methods.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":33744},"resolution":{"state":"partially_resolved","by":"pr-33744"},"freshness":{"state":"verified","last_verified_at":"2026-05-28T08:33:10+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.27199999999999996},"fix_edges":[{"issue_number":33538,"pr_number":33744,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":33538,"title":"openai-codex OAuth reauthorization can leave stale credential; auth add workaround required","url":null,"state":"closed"},{"kind":"issue","number":42712,"title":"[Bug]: hermes auth add openai-codex does not go to Oauth","url":null,"state":"closed"},{"kind":"issue","number":43461,"title":"[Bug]: OpenAI Codex usage completely Broken/Being Treated as Custom API","url":null,"state":"closed"},{"kind":"pr","number":33744,"title":"fix(auth): sync manual:device_code Codex pool entries on re-auth","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/auth.py","tests/hermes_cli/test_auth_codex_provider.py"],"fix_hunks":[{"file":"hermes_cli/auth.py","hunks":["@@ -3312,16 +3312,38 @@ def _sync_codex_pool_entries(\n     tokens: [redacted:secret-assignment] str],\n     last_refresh: Optional[str],\n ) -> None:\n-    \"\"\"Mirror a fresh Codex re-auth into the credential_pool singleton entries.\n+    \"\"\"Mirror a fresh Codex re-auth into the credential_pool OAuth entries.\n \n     The runtime selects credentials from ``credential_pool.openai-codex``, not\n     from ``providers.openai-codex.tokens``.  A re-auth invalidates the prior\n-    OAuth pair server-side, but the pool's ``device_code`` entry keeps holding\n-    the now-consumed refresh token plus any stale error markers — so the next\n-    request spends a dead token and gets a 401 ``token_invalidated``.  Update\n-    the singleton-seeded entries in lockstep with the provider tokens and clear\n-    the error state so the fresh credentials take effect immediately.  Manual\n-    (``manual:*``) entries are independent credentials and are left untouched.\n+    OAuth pair server-side, but pool entries keep holding the now-consumed\n+    refresh token plus any stale error markers — so the next request spends a\n+    dead token and gets a 401 ``token_invalidated``.\n+\n+    What gets refreshed:\n+\n+    * ``device_code`` — the singleton-seeded entry written by the device-code\n+      OAuth flow when the user logged in via ``hermes setup`` / the model\n+      picker.  Always synced with the fresh tokens.\n+    * ``manual:device_code`` — entries created by ``hermes auth add openai-codex``\n+      that use the same device-code OAuth mechanism.  An interactive re-auth\n+      proves the user owns the ChatGPT account, so it is safe (and expected)\n+      to refresh these entries too.  Without this, a user who once ran the\n+      ``hermes auth add`` workaround for #33000 would silently leave that\n+      manual entry stale on every subsequent re-auth, recreating the issue\n+      reported in #33538.\n+\n+    What does NOT get refreshed:\n+\n+    * ``manual:api_key`` and any other non-device-code manual sources — those\n+      are independent credentials (an explicit API key, a different ChatGPT\n+      account, etc.) and must not be overwritten by a single re-auth.\n+\n+    Error markers (``last_status``, ``last_error_*``) are also cleared on\n+    every device-code-backed entry — even those whose tokens we did not\n+    rewrite — so that an interactive re-auth gives every relevant pool entry\n+    a fresh selection chance instead of leaving them marked unhealthy from a\n+    pre-re-auth 401.\n     \"\"\"\n     access_token = [redacted:secret-assignment]\"access_token\")\n     if not access_token:","@@ -3333,8 +3355,15 @@ def _sync_codex_pool_entries(\n     entries = pool.get(\"openai-codex\")\n     if not isinstance(entries, list):\n         return\n+    # Sources whose tokens should be rewritten by a fresh Codex device-code\n+    # OAuth re-auth.  ``manual:api_key`` and unknown sources are intentionally\n+    # excluded — they represent independent credentials.\n+    REFRESHABLE_SOURCES = {\"device_code\", \"manual:device_code\"}\n     for entry in entries:\n-        if not isinstance(entry, dict) or entry.get(\"source\") != \"device_code\":\n+        if not isinstance(entry, dict):\n+            continue\n+        source = entry.get(\"source\")\n+        if source not in REFRESHABLE_SOURCES:\n             continue\n         entry[\"access_token\"] = access_token\n         if refresh_token:"]},{"file":"tests/hermes_cli/test_auth_codex_provider.py","hunks":["@@ -303,6 +303,88 @@ def test_save_codex_tokens_syncs_credential_pool(tmp_path, monkeypatch):\n     assert auth[\"providers\"][\"openai-codex\"][\"tokens\"][\"access_token\"] == \"new-at\"\n \n \n+def test_save_codex_tokens_syncs_manual_device_code_entries(tmp_path, monkeypatch):\n+    \"\"\"Re-auth must also refresh ``manual:device_code`` pool entries.\n+\n+    Regression for #33538: a user who hit #33000 before the #33164 fix landed\n+    would have run ``hermes auth add openai-codex`` as a workaround, leaving\n+    a pool entry with ``source=\"manual:device_code\"``.  On every subsequent\n+    re-auth via setup/model picker, the singleton-seeded ``device_code`` entry\n+    got refreshed but the ``manual:device_code`` entry stayed stale, recreating\n+    the same 401 token_invalidated symptom that #33164 was supposed to fix.\n+\n+    An interactive Codex device-code re-auth proves the user owns the ChatGPT\n+    account, so it is safe to refresh every device-code-backed entry in the\n+    pool — but NOT independent ``manual:api_key`` entries (separate accounts /\n+    explicit API keys).\n+    \"\"\"\n+    hermes_home = tmp_path / \"hermes\"\n+    hermes_home.mkdir(parents=True, exist_ok=True)\n+    (hermes_home / \"auth.json\").write_text(json.dumps({\n+        \"version\": 1,\n+        \"providers\": {\n+            \"openai-codex\": {\n+                \"tokens\": {\"access_token\": \"old-at\", \"refresh_token\": \"old-rt\"},\n+                \"last_refresh\": \"2026-01-01T00:00:00Z\",\n+                \"auth_mode\": \"chatgpt\",\n+            },\n+        },\n+        \"credential_pool\": {\n+            \"openai-codex\": [\n+                {\n+                    \"id\": \"seeded\",\n+                    \"source\": \"device_code\",\n+                    \"auth_type\": \"oauth\",\n+                    \"access_token\": \"old-at\",\n+                    \"refresh_token\": \"old-rt\",\n+                },\n+                {\n+                    \"id\": \"auth-add\",\n+                    \"source\": \"manual:device_code\",\n+                    \"auth_type\": \"oauth\",\n+                    \"access_token\": \"stale-manual-at\",\n+                    \"refresh_token\": \"stale-manual-rt\",\n+                    \"last_status\": \"exhausted\",\n+                    \"last_error_code\": 401,\n+                    \"last_error_reason\": \"token_invalidated\",\n+                },\n+                {\n+                    \"id\": \"api-key\",\n+                    \"source\": \"manual:api_key\",\n+                    \"auth_type\": \"api_key\",\n+                    \"access_token\": \"user-api-key\",\n+                },\n+            ],\n+        },\n+    }))\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+    _save_codex_tokens({\"access_token\": \"fresh-at\", \"refresh_token\": \"fresh-rt\"},\n+                       last_refresh=\"2026-05-28T00:00:00Z\")\n+\n+    auth = json.loads((hermes_home / \"auth.json\").read_text())\n+    pool = auth[\"credential_pool\"][\"openai-codex\"]\n+\n+    # Singleton-seeded device_code entry: refreshed and error markers cleared.\n+    seeded = next(e for e in pool if e[\"source\"] == \"device_code\")\n+    assert seeded[\"access_token\"] == \"fresh-at\"\n+    assert seeded[\"refresh_token\"] == \"fresh-rt\"\n+\n+    # manual:device_code entry: ALSO refreshed (the new behavior).\n+    manual_dc = next(e for e in pool if e[\"source\"] == \"manual:device_code\")\n+    assert manual_dc[\"access_token\"] == \"fresh-at\"\n+    assert manual_dc[\"refresh_token\"] == \"fresh-rt\"\n+    assert manual_dc[\"last_refresh\"] == \"2026-05-28T00:00:00Z\"\n+    assert manual_dc[\"last_status\"] is None\n+    assert manual_dc[\"last_error_code\"] is None\n+    assert manual_dc[\"last_error_reason\"] is None\n+\n+    # manual:api_key entry: untouched — independent credential.\n+    api_key = [redacted:secret-assignment] for e in pool if e[\"source\"] == \"manual:api_key\")\n+    assert api_key[\"access_token\"] == \"user-api-key\"\n+    assert \"refresh_token\" not in api_key or api_key.get(\"refresh_token\") is None\n+\n+\n def test_import_codex_cli_tokens(tmp_path, monkeypatch):\n     codex_home = tmp_path / \"codex-cli\"\n     codex_home.mkdir(parents=True, exist_ok=True)\n"]}]},{"kind":"pr","number":36932,"title":"config-driven MOA; Credential pool reload on /model switch, Incremental token count for sessions","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}