diff --git a/tests/tools/test_tool_backend_helpers.py b/tests/tools/test_tool_backend_helpers.py index 03bb7f20d7ae..9bb0522e347d 100644 --- a/tests/tools/test_tool_backend_helpers.py +++ b/tests/tools/test_tool_backend_helpers.py @@ -401,3 +401,73 @@ class TestResolveOpenaiAudioApiKey: monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", " voice-key ") monkeypatch.delenv("OPENAI_API_KEY", raising=False) assert resolve_openai_audio_api_key() == "voice-key" + + +# --------------------------------------------------------------------------- +# resolve_openai_audio_api_key — profile secret scope +# --------------------------------------------------------------------------- +class TestResolveOpenaiAudioApiKeyIsProfileScoped: + """The key this returns authenticates the TTS/STT client. + + In a multiplex gateway ``os.environ`` holds whichever profile's ``.env`` + loaded at boot, not the profile the current turn belongs to — so a raw + read here would let one profile's voice reply or voice-note transcription + run on (and be billed to) another profile's OpenAI account. Same contract + ``agent/vertex_adapter`` and the WeChat send path already follow. + """ + + @pytest.fixture(autouse=True) + def _reset_multiplex(self): + from agent import secret_scope as ss + + ss.set_multiplex_active(False) + yield + ss.set_multiplex_active(False) + + def test_scope_wins_over_another_profiles_environ(self, monkeypatch): + from agent import secret_scope as ss + + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "sk-other-profile") + ss.set_multiplex_active(True) + token = ss.set_secret_scope({"OPENAI_API_KEY": "sk-this-profile"}) + try: + assert resolve_openai_audio_api_key() == "sk-this-profile", ( + "voice/STT authenticated with another profile's OpenAI key" + ) + finally: + ss.reset_secret_scope(token) + + def test_scope_miss_does_not_borrow_another_profiles_key(self, monkeypatch): + """Under multiplexing an absent key must stay absent, not fall through.""" + from agent import secret_scope as ss + + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "sk-other-profile") + ss.set_multiplex_active(True) + token = ss.set_secret_scope({"UNRELATED": "x"}) + try: + assert resolve_openai_audio_api_key() == "" + finally: + ss.reset_secret_scope(token) + + def test_voice_key_precedence_holds_inside_a_scope(self, monkeypatch): + from agent import secret_scope as ss + + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + ss.set_multiplex_active(True) + token = ss.set_secret_scope({ + "VOICE_TOOLS_OPENAI_KEY": "sk-voice", + "OPENAI_API_KEY": "sk-general", + }) + try: + assert resolve_openai_audio_api_key() == "sk-voice" + finally: + ss.reset_secret_scope(token) + + def test_single_profile_still_reads_environ(self, monkeypatch): + """Control: no multiplexing, no scope — unchanged behaviour.""" + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "sk-plain") + assert resolve_openai_audio_api_key() == "sk-plain" diff --git a/tools/tool_backend_helpers.py b/tools/tool_backend_helpers.py index 95e753048e46..c28a9141141e 100644 --- a/tools/tool_backend_helpers.py +++ b/tools/tool_backend_helpers.py @@ -138,12 +138,36 @@ def resolve_modal_backend_state( } +def _scoped_credential(name: str) -> str: + """Read a credential env var under the active profile secret scope. + + Falls back to a raw read only when ``agent.secret_scope`` cannot be + imported, so a packaging edge never leaves the caller without a key. + """ + try: + from agent.secret_scope import get_secret + + return (get_secret(name, "") or "").strip() + except Exception: # pragma: no cover — secret_scope is in-repo + return (os.getenv(name, "") or "").strip() + + def resolve_openai_audio_api_key() -> str: - """Prefer the voice-tools key, but fall back to the normal OpenAI key.""" + """Prefer the voice-tools key, but fall back to the normal OpenAI key. + + Routed through the profile secret scope rather than reading ``os.environ`` + directly: in a multiplex gateway serving several profiles from one + process, ``os.environ`` reflects whichever profile's ``.env`` happened to + load at boot, not the profile the current turn belongs to. A raw read here + lets one profile's TTS reply / voice-note transcription authenticate as — + and get billed against — a different profile's OpenAI account. Same + routing the WeChat send path and ``agent/vertex_adapter`` already use; see + ``agent/secret_scope.py``. + """ return ( - os.getenv("VOICE_TOOLS_OPENAI_KEY", "") - or os.getenv("OPENAI_API_KEY", "") - ).strip() + _scoped_credential("VOICE_TOOLS_OPENAI_KEY") + or _scoped_credential("OPENAI_API_KEY") + ) def prefers_gateway(config_section: str) -> bool: