{"repo":"NousResearch/hermes-agent","public_id":"2962","display_label":"OAuth token refresh endpoint and reliability fixes","summary":"Multiple issues and fixes addressing OAuth token refresh failures, including wrong endpoints causing 401/403 errors, migration to platform.claude.com, atomic credential writes, stale detection, and removal of an unused PKCE flow.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":3246},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-03-26T20:26:57+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"issue_number":2962,"pr_number":3246,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":2962,"title":"[Bug]: OAuth token refresh fails on headless gateway — wrong endpoint + no recovery, causing persistent 401s with Anthropic Max","url":null,"state":"closed"},{"kind":"issue","number":6347,"title":"Anthropic OAuth refresh path gets Cloudflare 403 and leaves Hermes PKCE creds unrefreshable","url":null,"state":"closed"},{"kind":"pr","number":2968,"title":"fix(oauth): use platform.claude.com endpoint for token refresh + startup warning","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3246","number":3246}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":3246,"title":"fix(auth): migrate OAuth token refresh to platform.claude.com with fallback","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -210,9 +210,12 @@ def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:\n     Only works for credentials that have a refresh token (from claude /login\n     or claude setup-token with OAuth flow).\n \n+    Tries the new platform.claude.com endpoint first (Claude Code >=2.1.81),\n+    then falls back to console.anthropic.com for older tokens.\n+\n     Returns the new access token, or None if refresh fails.\n     \"\"\"\n-    import urllib.parse\n+    import time\n     import urllib.request\n \n     refresh_token = [redacted:secret-assignment]\"refreshToken\", \"\")","@@ -223,38 +226,42 @@ def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:\n     # Client ID used by Claude Code's OAuth flow\n     CLIENT_ID = \"9d1c250a-e61b-44d9-88ed-5944d1962f5e\"\n \n-    data = urllib.parse.urlencode({\n+    # Anthropic migrated OAuth from console.anthropic.com to platform.claude.com\n+    # (Claude Code v2.1.81+). Try new endpoint first, fall back to old.\n+    token_endpoints = [\n+        \"https://platform.claude.com/v1/oauth/token\",\n+        \"https://console.anthropic.com/v1/oauth/token\",\n+    ]\n+\n+    payload = json.dumps({\n         \"grant_type\": \"refresh_token\",\n         \"refresh_token\": refresh_token,\n         \"client_id\": CLIENT_ID,\n     }).encode()\n \n-    req = urllib.request.Request(\n-        \"https://console.anthropic.com/v1/oauth/token\",\n-        data=data,\n-        headers={\n-            \"Content-Type\": \"application/x-www-form-urlencoded\",\n-            \"User-Agent\": f\"claude-cli/{_CLAUDE_CODE_VERSION} (external, cli)\",\n-        },\n-        method=\"POST\",\n-    )\n+    headers = {\n+        \"Content-Type\": \"application/json\",\n+        \"User-Agent\": f\"claude-cli/{_CLAUDE_CODE_VERSION} (external, cli)\",\n+    }\n \n-    try:\n-        with urllib.request.urlopen(req, timeout=10) as resp:\n-            result = json.loads(resp.read().decode())\n-            new_access = result.get(\"access_token\", \"\")\n-            new_refresh = result.get(\"refresh_token\", refresh_token)\n-            expires_in = result.get(\"expires_in\", 3600)  # seconds\n-\n-            if new_access:\n-                import time\n-                new_expires_ms = int(time.time() * 1000) + (expires_in * 1000)\n-                # Write refreshed credentials back to ~/.claude/.credentials.json\n-                _write_claude_code_credentials(new_access, new_refresh, new_expires_ms)\n-                logger.debug(\"Successfully refreshed Claude Code OAuth token\")\n-                return new_access\n-    except Exception as e:\n-        logger.debug(\"Failed to refresh Claude Code token: %s\", e)\n+    for endpoint in token_endpoints:\n+        req = urllib.request.Request(\n+            endpoint, data=payload, headers=headers, method=\"POST\",\n+        )\n+        try:\n+            with urllib.request.urlopen(req, timeout=10) as resp:\n+                result = json.loads(resp.read().decode())\n+                new_access = result.get(\"access_token\", \"\")\n+                new_refresh = result.get(\"refresh_token\", refresh_token)\n+                expires_in = result.get(\"expires_in\", 3600)\n+\n+                if new_access:\n+                    new_expires_ms = int(time.time() * 1000) + (expires_in * 1000)\n+                    _write_claude_code_credentials(new_access, new_refresh, new_expires_ms)\n+                    logger.debug(\"Refreshed Claude Code OAuth token via %s\", endpoint)\n+                    return new_access\n+        except Exception as e:\n+            logger.debug(\"Token refresh failed at %s: %s\", endpoint, e)\n \n     return None\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}