diff --git a/tests/tools/test_tts_command_providers.py b/tests/tools/test_tts_command_providers.py index 5b5701c0d829..97381924fba1 100644 --- a/tests/tools/test_tts_command_providers.py +++ b/tests/tools/test_tts_command_providers.py @@ -663,3 +663,49 @@ class TestCheckTtsRequirements: } with patch("tools.tts_tool._load_tts_config", return_value=cfg): assert check_tts_requirements() is True + + +class TestCommandTtsEnvPassthrough: + def test_env_passthrough_restores_named_keys(self, monkeypatch): + """A provider's env_passthrough allowlist re-adds its own API key + without unscrubbing everything else.""" + monkeypatch.setenv("MY_TTS_API_KEY", "sk-provider") + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + + captured = {} + + class _Stream: + def read(self, size): + return "" + + class Proc: + returncode = 0 + stdout = _Stream() + stderr = _Stream() + + def wait(self, timeout=None): + return 0 + + def fake_popen(command, **kwargs): + captured["env"] = kwargs["env"] + return Proc() + + monkeypatch.setattr("tools.tts_tool.subprocess.Popen", fake_popen) + + result = _run_command_tts( + "echo hi", timeout=1, env_passthrough=["MY_TTS_API_KEY"] + ) + + assert result.returncode == 0 + env = captured["env"] + assert env["MY_TTS_API_KEY"] == "sk-provider" + assert "OPENAI_API_KEY" not in env + + def test_allowlist_parsed_from_provider_config(self): + from tools.tts_tool import _command_provider_env_passthrough + + assert _command_provider_env_passthrough( + {"env_passthrough": ["A_KEY", " B_KEY ", ""]} + ) == ["A_KEY", "B_KEY"] + assert _command_provider_env_passthrough({}) == [] + assert _command_provider_env_passthrough({"env_passthrough": "A_KEY"}) == [] diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 042f05aa646c..2dc88f78fbbe 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -654,7 +654,26 @@ def _terminate_command_stt_process_tree(proc: subprocess.Popen) -> None: proc.kill() -def _run_command_stt(command: str, timeout: float) -> subprocess.CompletedProcess: +def _command_stt_env_passthrough(config: Dict[str, Any]) -> list: + """Return the provider's ``env_passthrough`` allowlist (opt-out of scrub). + + Command providers legitimately reference their own API keys in the shell + template (curl one-liners). The child env is scrubbed of Hermes secrets by + default; ``env_passthrough: [MY_API_KEY, ...]`` copies the named variables + back from the parent environment so a trusted template keeps working. + Mirrors ``tools.tts_tool._command_provider_env_passthrough``. + """ + raw = config.get("env_passthrough") + if not isinstance(raw, (list, tuple)): + return [] + return [str(item).strip() for item in raw if str(item).strip()] + + +def _run_command_stt( + command: str, + timeout: float, + env_passthrough: Optional[list] = None, +) -> subprocess.CompletedProcess: """Run a command-provider shell command with process-tree idle cleanup. Mirrors ``tools.tts_tool._run_command_tts``: ``timeout`` is an IDLE @@ -668,6 +687,10 @@ def _run_command_stt(command: str, timeout: float) -> subprocess.CompletedProces from tools.environments.local import hermes_subprocess_env scrubbed = hermes_subprocess_env(inherit_credentials=False) + for key in env_passthrough or []: + value = os.environ.get(key) + if value is not None: + scrubbed[key] = value popen_kwargs: Dict[str, Any] = { "shell": True, "stdout": subprocess.PIPE, @@ -879,7 +902,11 @@ def _transcribe_command_stt( audio.name, provider_name, ) try: - result = _run_command_stt(command, timeout) + result = _run_command_stt( + command, + timeout, + env_passthrough=_command_stt_env_passthrough(config), + ) except subprocess.TimeoutExpired: return { "success": False, diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 0d59c3100569..c1febc274db2 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -1001,7 +1001,25 @@ def _terminate_command_tts_process_tree(proc: subprocess.Popen) -> None: proc.kill() -def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProcess: +def _command_provider_env_passthrough(config: Dict[str, Any]) -> list: + """Return the provider's ``env_passthrough`` allowlist (opt-out of scrub). + + Command providers legitimately reference their own API keys in the shell + template (curl one-liners). The child env is scrubbed of Hermes secrets by + default; ``env_passthrough: [MY_API_KEY, ...]`` copies the named variables + back from the parent environment so a trusted template keeps working. + """ + raw = config.get("env_passthrough") + if not isinstance(raw, (list, tuple)): + return [] + return [str(item).strip() for item in raw if str(item).strip()] + + +def _run_command_tts( + command: str, + timeout: float, + env_passthrough: Optional[list] = None, +) -> subprocess.CompletedProcess: """Run a command-provider shell command with process-tree idle cleanup. Child env is scrubbed of Hermes secrets (salvage of #56332) while still @@ -1011,6 +1029,10 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces from tools.environments.local import hermes_subprocess_env scrubbed = hermes_subprocess_env(inherit_credentials=False) + for key in env_passthrough or []: + value = os.environ.get(key) + if value is not None: + scrubbed[key] = value popen_kwargs: Dict[str, Any] = { "shell": True, "stdout": subprocess.PIPE, @@ -1172,7 +1194,11 @@ def _generate_command_tts( command = _render_command_tts_template(command_template, placeholders) try: - _run_command_tts(command, timeout) + _run_command_tts( + command, + timeout, + env_passthrough=_command_provider_env_passthrough(config), + ) except subprocess.TimeoutExpired as exc: raise RuntimeError( f"TTS provider '{provider_name}' timed out after {timeout:g}s" diff --git a/website/docs/user-guide/features/tts.md b/website/docs/user-guide/features/tts.md index 7d3cfed0c49f..9c20ca79968f 100644 --- a/website/docs/user-guide/features/tts.md +++ b/website/docs/user-guide/features/tts.md @@ -286,6 +286,18 @@ tts: **Supported `output_format` values:** `mp3` (default), `wav`, `ogg`, `flac`, `m4a`, `aac`, `amr`, `opus`. Your command must actually produce that format (e.g. via `ffmpeg`); Hermes only validates the declared value and names the output file accordingly. An unknown value falls back to `mp3`. The chosen format is also exposed to the command as the `{format}` placeholder. +**Subprocess environment:** command providers (TTS and STT) run with Hermes secrets scrubbed from the child environment — gateway bot tokens, LLM provider API keys, and internal relay credentials are removed; `PATH`, `HOME`, locale, and other normal variables are kept. If your command template needs its own API key from the environment (e.g. a `curl` one-liner), list the variable names under `env_passthrough` in the provider config: + +```yaml +tts: + providers: + mycloud: + type: command + command: 'curl -s -H "Authorization: Bearer $MYCLOUD_API_KEY" ... -o {output_path}' + env_passthrough: [MYCLOUD_API_KEY] +``` + + #### Example: Doubao (Chinese seed-tts-2.0) For high-quality Chinese TTS via ByteDance's [seed-tts-2.0](https://www.volcengine.com/docs/6561/1257544) bidirectional-streaming API, install the [`doubao-speech`](https://pypi.org/project/doubao-speech/) PyPI package and wire it in as a command provider: