From ed591a56640514cdcb5229c4bd51476b44adaccd Mon Sep 17 00:00:00 2001 From: Drexuxux Date: Wed, 22 Jul 2026 16:29:36 +0300 Subject: [PATCH] fix(voice): resolve the TTS/STT OpenAI key under the profile secret scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve_openai_audio_api_key()` reads the key that authenticates the audio client straight from the process environment: return ( os.getenv("VOICE_TOOLS_OPENAI_KEY", "") or os.getenv("OPENAI_API_KEY", "") ).strip() That value is not advisory. It flows through `_resolve_openai_audio_client_config()` into `OpenAIClient(api_key=...)` for TTS, and through `transcription_tools` for voice-note STT — both on the per-turn tool path, inside the profile secret scope the gateway installs. `agent/vertex_adapter` states the contract this breaks: in a multiplex gateway serving several profiles from one process, os.environ reflects whichever profile's .env happened to be loaded at boot, not the profile the current turn belongs to. Reading it directly here would let one profile mint tokens from — and get billed against — a different profile's service-account file. Reproduced with the real resolver, multiplexing on and profile A's scope installed: scope-aware get_secret -> sk-PROFILE-A-key voice/STT resolver -> sk-PROFILE-B-key So profile A's spoken reply and its users' voice notes are sent to OpenAI on profile B's account, and billed there. Route both reads through `agent.secret_scope.get_secret`, the same fix already merged for the WeChat send path (#59662) and pending for QQ (#60420) — neither covers the audio credential family. Under multiplexing the scope stays authoritative, so a scope miss now yields no key instead of borrowing another profile's; with multiplexing off `get_secret` falls through to `os.environ` exactly as before, so single-profile deployments are untouched. The VOICE_TOOLS_OPENAI_KEY > OPENAI_API_KEY precedence is unchanged. Deliberately narrow: `fal_key_is_configured()` and `has_direct_modal_credentials()` in this file are presence checks, not authentication, and the former is already being reworked in open PR #20929. tests/tools/test_tool_backend_helpers.py: the scope wins over another profile's `os.environ`; a scope miss does not borrow another profile's key; voice-key precedence holds inside a scope; and a control proves the single-profile path still reads `os.environ`. The three isolation tests fail on main; the control passes there. 320 passed across the helper, secret-scope, and consumer suites (the fluctuating voice_mode/voice_cli failures are pre-existing PulseAudio/ordering artifacts — the differing test passes 3/3 in isolation on both main and this branch). --- tests/tools/test_tool_backend_helpers.py | 70 ++++++++++++++++++++++++ tools/tool_backend_helpers.py | 32 +++++++++-- 2 files changed, 98 insertions(+), 4 deletions(-) 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: