fix(voice): resolve the TTS/STT OpenAI key under the profile secret scope

`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).
This commit is contained in:
Drexuxux 2026-07-22 16:29:36 +03:00 committed by Teknium
parent d464ae3652
commit ed591a5664
2 changed files with 98 additions and 4 deletions

View file

@ -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"

View file

@ -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: