diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 8a625fbb2e..acb60db6ca 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1547,12 +1547,21 @@ def refresh_codex_oauth_pure( try: err = response.json() if isinstance(err, dict): - err_code = err.get("error") - if isinstance(err_code, str) and err_code.strip(): - code = err_code.strip() - err_desc = err.get("error_description") or err.get("message") - if isinstance(err_desc, str) and err_desc.strip(): - message = f"Codex token refresh failed: {err_desc.strip()}" + err_obj = err.get("error") + # OpenAI shape: {"error": {"code": "...", "message": "...", "type": "..."}} + if isinstance(err_obj, dict): + nested_code = err_obj.get("code") or err_obj.get("type") + if isinstance(nested_code, str) and nested_code.strip(): + code = nested_code.strip() + nested_msg = err_obj.get("message") + if isinstance(nested_msg, str) and nested_msg.strip(): + message = f"Codex token refresh failed: {nested_msg.strip()}" + # OAuth spec shape: {"error": "code_str", "error_description": "..."} + elif isinstance(err_obj, str) and err_obj.strip(): + code = err_obj.strip() + err_desc = err.get("error_description") or err.get("message") + if isinstance(err_desc, str) and err_desc.strip(): + message = f"Codex token refresh failed: {err_desc.strip()}" except Exception: pass if code in {"invalid_grant", "invalid_token", "invalid_request"}: diff --git a/tests/hermes_cli/test_auth_codex_provider.py b/tests/hermes_cli/test_auth_codex_provider.py index ddcaf1721f..3d25983794 100644 --- a/tests/hermes_cli/test_auth_codex_provider.py +++ b/tests/hermes_cli/test_auth_codex_provider.py @@ -17,6 +17,7 @@ from hermes_cli.auth import ( _import_codex_cli_tokens, get_codex_auth_status, get_provider_auth_state, + refresh_codex_oauth_pure, resolve_codex_runtime_credentials, resolve_provider, ) @@ -190,3 +191,123 @@ def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch): assert creds["source"] == "hermes-auth-store" assert creds["provider"] == "openai-codex" assert creds["base_url"] == DEFAULT_CODEX_BASE_URL + + +class _StubHTTPResponse: + def __init__(self, status_code: int, payload): + self.status_code = status_code + self._payload = payload + self.text = json.dumps(payload) if isinstance(payload, (dict, list)) else str(payload) + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + +class _StubHTTPClient: + def __init__(self, response): + self._response = response + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def post(self, *args, **kwargs): + return self._response + + +def _patch_httpx(monkeypatch, response): + def _factory(*args, **kwargs): + return _StubHTTPClient(response) + + monkeypatch.setattr("hermes_cli.auth.httpx.Client", _factory) + + +def test_refresh_parses_openai_nested_error_shape_refresh_token_reused(monkeypatch): + """OpenAI returns {"error": {"code": "refresh_token_reused", "message": "..."}} + — parser must surface relogin_required and the dedicated message. + """ + response = _StubHTTPResponse( + 401, + { + "error": { + "message": "Your refresh token has already been used to generate a new access token. Please try signing in again.", + "type": "invalid_request_error", + "param": None, + "code": "refresh_token_reused", + } + }, + ) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("access-old", "refresh-old") + + err = exc_info.value + assert err.code == "refresh_token_reused" + assert err.relogin_required is True + # The existing dedicated branch should override the message with actionable guidance. + assert "already consumed by another client" in str(err) + + +def test_refresh_parses_openai_nested_error_shape_generic_code(monkeypatch): + """Nested error with arbitrary code still surfaces code + message.""" + response = _StubHTTPResponse( + 400, + { + "error": { + "message": "Invalid client credentials.", + "type": "invalid_request_error", + "code": "invalid_client", + } + }, + ) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("access-old", "refresh-old") + + err = exc_info.value + assert err.code == "invalid_client" + assert "Invalid client credentials." in str(err) + + +def test_refresh_parses_oauth_spec_flat_error_shape_invalid_grant(monkeypatch): + """Fallback path: OAuth spec-shape {"error": "invalid_grant", "error_description": "..."} + must still map to relogin_required=True via the existing code set. + """ + response = _StubHTTPResponse( + 400, + { + "error": "invalid_grant", + "error_description": "Refresh token is expired or revoked.", + }, + ) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("access-old", "refresh-old") + + err = exc_info.value + assert err.code == "invalid_grant" + assert err.relogin_required is True + assert "Refresh token is expired or revoked." in str(err) + + +def test_refresh_falls_back_to_generic_message_on_unparseable_body(monkeypatch): + """No JSON body → generic 'with status 401' message; 401 always forces relogin.""" + response = _StubHTTPResponse(401, ValueError("not json")) + _patch_httpx(monkeypatch, response) + + with pytest.raises(AuthError) as exc_info: + refresh_codex_oauth_pure("access-old", "refresh-old") + + err = exc_info.value + assert err.code == "codex_refresh_failed" + # 401/403 from the token endpoint always means the refresh token is + # invalid/expired — force relogin even without a parseable error body. + assert err.relogin_required is True + assert "status 401" in str(err)