mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
d464ae3652
commit
ed591a5664
2 changed files with 98 additions and 4 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue