diff --git a/run_agent.py b/run_agent.py index 55b4efaa677..48382389eaf 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9037,6 +9037,11 @@ class AIAgent: self.api_key = _clean_key if isinstance(getattr(self, "_client_kwargs", None), dict): self._client_kwargs["api_key"] = _clean_key + # Also update the live client — it holds its + # own copy of api_key which auth_headers reads + # dynamically on every request. + if getattr(self, "client", None) is not None and hasattr(self.client, "api_key"): + self.client.api_key = _clean_key _credential_sanitized = True self._vprint( f"{self.log_prefix}⚠️ API key contained non-ASCII characters " diff --git a/tests/run_agent/test_unicode_ascii_codec.py b/tests/run_agent/test_unicode_ascii_codec.py index ef4f3f339d4..a8a52c34ae3 100644 --- a/tests/run_agent/test_unicode_ascii_codec.py +++ b/tests/run_agent/test_unicode_ascii_codec.py @@ -230,3 +230,67 @@ class TestSanitizeStructureNonAscii: assert _sanitize_structure_non_ascii(payload) is True assert payload["default_headers"]["X-Title"] == "Hermes Agent" assert payload["default_headers"]["User-Agent"] == "Hermes/1.0 " + + +class TestApiKeyClientSync: + """Verify that ASCII recovery updates the live OpenAI client's api_key. + + The OpenAI SDK stores its own copy of api_key which auth_headers reads + dynamically. If only self.api_key is updated but self.client.api_key + is not, the next request still sends the corrupted key in the + Authorization header. + """ + + def test_client_api_key_updated_on_sanitize(self): + """Simulate the recovery path and verify client.api_key is synced.""" + from unittest.mock import MagicMock + from run_agent import AIAgent + + agent = AIAgent.__new__(AIAgent) + bad_key = "sk-proj-abc\u028bdef" # ʋ lookalike at position 11 + agent.api_key = bad_key + agent._client_kwargs = {"api_key": bad_key} + agent.quiet_mode = True + + # Mock client with its own api_key attribute (like the real OpenAI client) + mock_client = MagicMock() + mock_client.api_key = bad_key + agent.client = mock_client + + # --- replicate the recovery logic from run_agent.py --- + _raw_key = agent.api_key + _clean_key = _strip_non_ascii(_raw_key) + assert _clean_key != _raw_key, "test precondition: key should have non-ASCII" + + agent.api_key = _clean_key + agent._client_kwargs["api_key"] = _clean_key + if getattr(agent, "client", None) is not None and hasattr(agent.client, "api_key"): + agent.client.api_key = _clean_key + + # All three locations should now hold the clean key + assert agent.api_key == "sk-proj-abcdef" + assert agent._client_kwargs["api_key"] == "sk-proj-abcdef" + assert agent.client.api_key == "sk-proj-abcdef" + # The bad char should be gone from all of them + assert "\u028b" not in agent.api_key + assert "\u028b" not in agent._client_kwargs["api_key"] + assert "\u028b" not in agent.client.api_key + + def test_client_none_does_not_crash(self): + """Recovery should not crash when client is None (pre-init).""" + from run_agent import AIAgent + + agent = AIAgent.__new__(AIAgent) + bad_key = "sk-proj-\u028b" + agent.api_key = bad_key + agent._client_kwargs = {"api_key": bad_key} + agent.client = None + + _clean_key = _strip_non_ascii(bad_key) + agent.api_key = _clean_key + agent._client_kwargs["api_key"] = _clean_key + if getattr(agent, "client", None) is not None and hasattr(agent.client, "api_key"): + agent.client.api_key = _clean_key + + assert agent.api_key == "sk-proj-" + assert agent.client is None # should not have been touched