From e68ceadbc157a54fe2ba0428071d007da188a039 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sun, 21 Jun 2026 02:31:15 +0200 Subject: [PATCH 1/7] fix(audit): harden config loading and secrets sourcing --- app/config.py | 7 +++- run.sh | 16 ++++++-- tests/test_config.py | 38 +++++++++++++++++++ tests/test_run_script.py | 79 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 tests/test_run_script.py diff --git a/app/config.py b/app/config.py index 33ca3c3..e3d688c 100644 --- a/app/config.py +++ b/app/config.py @@ -112,8 +112,11 @@ def _load(self) -> dict[str, Any]: sanitized = dict(data) sanitized.pop("openai_api_key", None) return _deep_merge(DEFAULTS, sanitized) - except Exception: - logger.warning("Config could not be loaded, using defaults") + except json.JSONDecodeError: + logger.warning("Config file is not valid JSON, using defaults", exc_info=True) + return _deep_merge(DEFAULTS, {}) + except OSError: + logger.warning("Config file could not be read, using defaults", exc_info=True) return _deep_merge(DEFAULTS, {}) def save(self) -> None: diff --git a/run.sh b/run.sh index 8c62d77..24cca1c 100755 --- a/run.sh +++ b/run.sh @@ -23,10 +23,18 @@ trap 'rm -f "${LOCKFILE}"' EXIT INT TERM # --- secrets.env (optional) --- if [[ -f "${SECRETS_FILE}" ]]; then - set -a - # shellcheck disable=SC1090 - source "${SECRETS_FILE}" - set +a + if [[ ! -O "${SECRETS_FILE}" ]]; then + echo "WARNUNG: ${SECRETS_FILE} gehört nicht dem aktuellen Nutzer und wird nicht geladen." >&2 + else + SECRETS_PERMS=$(stat -c '%a' "${SECRETS_FILE}" 2>/dev/null || true) + if [[ -n "${SECRETS_PERMS}" ]] && (( 10#${SECRETS_PERMS} > 600 )); then + echo "WARNUNG: ${SECRETS_FILE} hat zu offene Rechte (${SECRETS_PERMS}); erwartet 600 oder restriktiver." >&2 + fi + set -a + # shellcheck disable=SC1090 + source "${SECRETS_FILE}" + set +a + fi fi # --- venv-Prüfung --- diff --git a/tests/test_config.py b/tests/test_config.py index 19157df..848ffdc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import logging from pathlib import Path import pytest @@ -262,6 +263,43 @@ def test_env_value_wins_over_legacy_fallback(self, config_dir, monkeypatch): assert loaded.resolve_openai_api_key() == "env-placeholder" +class TestLoadFailures: + def test_invalid_json_logs_json_decode_error_with_exc_info(self, config_dir, caplog): + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "config.json").write_text("{kaputt", encoding="utf-8") + + with caplog.at_level(logging.WARNING, logger="blitztext.config"): + loaded = BlitztextConfig(config_dir=config_dir) + + assert loaded.model == "base" + records = [r for r in caplog.records if "valid JSON" in r.message] + assert len(records) == 1 + assert records[0].exc_info is not None + assert records[0].exc_info[0] is json.JSONDecodeError + + def test_read_error_logs_os_error_with_exc_info(self, config_dir, caplog, monkeypatch): + config_dir.mkdir(parents=True, exist_ok=True) + config_file = config_dir / "config.json" + config_file.write_text("{}", encoding="utf-8") + original_read_text = Path.read_text + + def fake_read_text(self, *args, **kwargs): + if self == config_file: + raise PermissionError("denied") + return original_read_text(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", fake_read_text) + + with caplog.at_level(logging.WARNING, logger="blitztext.config"): + loaded = BlitztextConfig(config_dir=config_dir) + + assert loaded.model == "base" + records = [r for r in caplog.records if "could not be read" in r.message] + assert len(records) == 1 + assert records[0].exc_info is not None + assert records[0].exc_info[0] is PermissionError + + class TestUILanguage: """Tests für ui_language Config-Property.""" diff --git a/tests/test_run_script.py b/tests/test_run_script.py new file mode 100644 index 0000000..b80db04 --- /dev/null +++ b/tests/test_run_script.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + + +def _prepare_run_script(tmp_path: Path, repo_root: Path) -> Path: + script_path = tmp_path / "run.sh" + script_path.write_text((repo_root / "run.sh").read_text(encoding="utf-8"), encoding="utf-8") + script_path.chmod(0o755) + + stub_python = tmp_path / ".venv" / "bin" / "python" + stub_python.parent.mkdir(parents=True, exist_ok=True) + stub_python.write_text( + "#!/usr/bin/env bash\nprintf 'BT_SAMPLE_VAR=%s\\n' \"${BT_SAMPLE_VAR:-}\"\n", + encoding="utf-8", + ) + stub_python.chmod(0o755) + + app_dir = tmp_path / "app" + app_dir.mkdir(parents=True, exist_ok=True) + (app_dir / "blitztext_linux.py").write_text("print('noop')\n", encoding="utf-8") + return script_path + + +def _run_script(script_path: Path, home_dir: Path, runtime_dir: Path) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.update({ + "HOME": str(home_dir), + "XDG_RUNTIME_DIR": str(runtime_dir), + }) + proc = subprocess.Popen( + ["bash", str(script_path)], + cwd=str(script_path.parent), + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + stdout, stderr = proc.communicate() + return subprocess.CompletedProcess(proc.args, proc.returncode, stdout, stderr) + + +def test_run_script_warns_when_file_is_more_permissive_than_0600(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[1] + script_path = _prepare_run_script(tmp_path, repo_root) + home_dir = tmp_path / "home" + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + env_file = home_dir / ".config" / "blitztext-linux" / "secrets.env" + env_file.parent.mkdir(parents=True, exist_ok=True) + env_file.write_text("BT_SAMPLE_VAR=from-env\n", encoding="utf-8") + env_file.chmod(0o644) + + result = _run_script(script_path, home_dir, runtime_dir) + + assert result.returncode == 0 + assert "BT_SAMPLE_VAR=from-env" in result.stdout + assert "644" in result.stderr + assert "600" in result.stderr + + +def test_run_script_stays_quiet_when_file_permissions_are_0600(tmp_path: Path): + repo_root = Path(__file__).resolve().parents[1] + script_path = _prepare_run_script(tmp_path, repo_root) + home_dir = tmp_path / "home" + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + env_file = home_dir / ".config" / "blitztext-linux" / "secrets.env" + env_file.parent.mkdir(parents=True, exist_ok=True) + env_file.write_text("BT_SAMPLE_VAR=from-env\n", encoding="utf-8") + env_file.chmod(0o600) + + result = _run_script(script_path, home_dir, runtime_dir) + + assert result.returncode == 0 + assert "BT_SAMPLE_VAR=from-env" in result.stdout + assert result.stderr == "" From 9f54a2dc89a65a0b815c44ce4816488c392d4f70 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sun, 21 Jun 2026 02:31:15 +0200 Subject: [PATCH 2/7] refactor(audit): apply low-risk hardening cleanups --- app/llm_service.py | 22 ++++++++++++++++------ app/transcribe.py | 2 +- tests/test_llm_service.py | 9 ++++++++- tests/test_transcribe.py | 19 +++++++++++++++++++ 4 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 tests/test_transcribe.py diff --git a/app/llm_service.py b/app/llm_service.py index c0edfae..c7d7a82 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -40,6 +40,20 @@ class LLMServiceError(Exception): """Raised when an LLM call fails.""" +class _NullLLMClient: + """Minimaler Platzhalter, damit der Produktionspfad kein unittest.mock nutzt.""" + + class _Chat: + class _Completions: + @staticmethod + def create(*args, **kwargs): + raise RuntimeError("Null client cannot create completions") + + completions = _Completions() + + chat = _Chat() + + class LLMService: """Wraps OpenAI API calls for BlitztextLinux rewrite workflows.""" @@ -76,9 +90,7 @@ def __init__( except ImportError: self._openai_installed = False self._client_is_fallback_mock = True - from unittest.mock import MagicMock - - self.client = MagicMock() + self.client = _NullLLMClient() else: if self.api_key and self.api_key.strip(): self.client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url or None) @@ -87,9 +99,7 @@ def __init__( # werfen bereits im Konstruktor bei leerem Key. Der eigentliche # Fehler wird zur Aufrufzeit über _check_openai() klar gemeldet, # damit die App auch ohne gesetzten Key startet. - from unittest.mock import MagicMock - - self.client = MagicMock() + self.client = _NullLLMClient() def is_available(self) -> bool: return bool(self.api_key and self.api_key.strip()) diff --git a/app/transcribe.py b/app/transcribe.py index a3245a8..bd49147 100644 --- a/app/transcribe.py +++ b/app/transcribe.py @@ -59,7 +59,7 @@ def transcribe( TranscribeError: Bei fehlender Abhaengigkeit, fehlendem File oder Modell-Fehler. """ - wav_file = Path(wav_file) + wav_file = Path(wav_file).resolve() if model not in VALID_MODEL_NAMES: raise TranscribeError( diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 7ddab6d..4af68a4 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -6,7 +6,7 @@ import pytest -from app.llm_service import LLMService, LLMServiceError +from app.llm_service import LLMService, LLMServiceError, _NullLLMClient from app.workflows import WorkflowType from app.writing_presets import WRITING_PRESETS @@ -51,6 +51,13 @@ def test_custom_terms_are_stored(self, mock_client): class TestProviderConfig: + + def test_empty_api_key_uses_null_client_instead_of_mock(self): + fake_openai = MagicMock() + with patch.dict("sys.modules", {"openai": fake_openai}): + service = LLMService(api_key="", base_url="") + assert isinstance(service.client, _NullLLMClient) + def test_default_model_is_gpt_4o_mini(self, service): assert service.model == "gpt-4o-mini" diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py new file mode 100644 index 0000000..df88315 --- /dev/null +++ b/tests/test_transcribe.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from app.transcribe import transcribe + + +def test_transcribe_resolves_path_before_backend_call(tmp_path: Path): + wav_file = tmp_path / "sample.wav" + wav_file.write_bytes(b"RIFFdata") + + with patch("app.transcribe.Path.resolve", autospec=True, side_effect=lambda self: self) as resolve_mock, \ + patch("app.transcribe._transcribe_openai", return_value="OK") as backend_mock: + result = transcribe(wav_file, backend="openai-whisper") + + assert result == "OK" + resolve_mock.assert_called_once() + backend_mock.assert_called_once() From 33b49f2a8f4bcb00a409e90613af2f8b770b3593 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sun, 21 Jun 2026 02:31:15 +0200 Subject: [PATCH 3/7] docs(roadmap): update paket-h audit hardening status --- ROADMAP.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 281f54a..e486023 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,6 +25,50 @@ It is a planning note, not a promise. - Add a lightweight launch smoke test (boot the app offscreen and exit cleanly) so CI confirms the GUI actually starts, not just that mocked logic passes - Export transcribed text as a shareable audio file (e.g. OGG/Opus or MP3) so longer transcripts can be sent as a voice message via WhatsApp or similar (builds on the TTS pipeline) +## Paket H — Audit hardening (2026-06-21) + +Derived from an external code audit, cross-verified by three independent +reviews (Claude, `security-reviewer`, Codex). Verdict: neither of the two +findings flagged "critical" is actually critical for this single-user, +no-network desktop app. The items below are real-but-lower-severity hardening +and quality improvements, ordered for step-by-step execution. + +Phase 1 — worthwhile hardening (recommended): + +- [x] H1 `config.py` `_load()`: split the broad `except Exception` (lines + ~115-117) into `json.JSONDecodeError` and `PermissionError`/`OSError`, + each logged with `exc_info=True` so failures are debuggable. (MEDIUM) +- [x] H2 `run.sh` secrets sourcing (lines ~24-30): add an ownership/permission + check before `source` (`[[ -O "$SECRETS_FILE" ]]` + warn if perms are + looser than 600); keep `source`. Do NOT use the audit's + `export $(grep ... | xargs)` fix — it corrupts keys with spaces/special + chars via word-splitting. (MEDIUM, defense-in-depth) + +Phase 2 — optional cleanups (LOW): + +- [x] H3 `transcribe.py` (line ~62): `Path(wav_file).resolve()` as + defense-in-depth (no real traversal vector exists; path is app-generated). +- [x] H4 `llm_service.py`: replace the `MagicMock` production fallback with a + small `_NullLLMClient` stub so `unittest.mock` is not on the prod path. +- [ ] H5 `hotkey_service.py` (line ~319): optional short `time.sleep` after the + inner `break` as cheap insurance (no real busy-loop — `select(...,1.0)` + and fd-pop already prevent CPU spin). Deferred intentionally: optional only, + no reproduced bug and no strong red test yet. + +Phase 3 — only if desired / verify first: + +- [ ] H6 Make `paste_service._KEY_DELAY_MS` configurable via config. +- [ ] H7 `transcribe._transcribe_openai`: set `CUDA_VISIBLE_DEVICES` locally via + `subprocess(env=...)` instead of mutating the global process env. +- [ ] H8 Collapse the duplicate default model source (`llm_service.MODEL` vs + `config.py` default) to a single source of truth. +- [ ] H9 Verify (do not blindly remove) the `tts_openai_voice` validation set: + "marin"/"cedar" appear to be real newer OpenAI voices — the audit is + likely wrong here. + +Explicitly out: treating finding 2 (path traversal) as critical; adopting the +audit's flawed `run.sh` xargs fix. + ## Not in scope - Hosted backend services From b6141c0edd2f0ebac7284651bcc25bc50271d05a Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sun, 21 Jun 2026 02:37:10 +0200 Subject: [PATCH 4/7] refactor(audit): resolve h8 default-model drift and verify h9 --- ROADMAP.md | 7 ++++--- app/blitztext_linux.py | 4 ++-- app/llm_service.py | 5 +++-- tests/test_llm_service.py | 9 +++++---- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e486023..2e52172 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,11 +60,12 @@ Phase 3 — only if desired / verify first: - [ ] H6 Make `paste_service._KEY_DELAY_MS` configurable via config. - [ ] H7 `transcribe._transcribe_openai`: set `CUDA_VISIBLE_DEVICES` locally via `subprocess(env=...)` instead of mutating the global process env. -- [ ] H8 Collapse the duplicate default model source (`llm_service.MODEL` vs +- [x] H8 Collapse the duplicate default model source (`llm_service.MODEL` vs `config.py` default) to a single source of truth. -- [ ] H9 Verify (do not blindly remove) the `tts_openai_voice` validation set: +- [x] H9 Verify (do not blindly remove) the `tts_openai_voice` validation set: "marin"/"cedar" appear to be real newer OpenAI voices — the audit is - likely wrong here. + likely wrong here. Verified against the installed OpenAI client; no code + change needed. Explicitly out: treating finding 2 (path traversal) as critical; adopting the audit's flawed `run.sh` xargs fix. diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 0b2ad9c..4f99acd 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -30,7 +30,7 @@ if PROJECT_DIR not in sys.path: sys.path.insert(0, PROJECT_DIR) -from app.config import Config, VALID_HOTKEY_KEYS +from app.config import Config, DEFAULTS, VALID_HOTKEY_KEYS from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS, LLMServiceError from app.writing_presets import WRITING_PRESET_KEYS, get_preset, preset_index from app.hotkey_service import HotkeyWorker @@ -273,7 +273,7 @@ def init_ui(self) -> None: self.edit_llm_model = QLineEdit() self.edit_llm_model.setText(self.config.llm_model) - self.edit_llm_model.setPlaceholderText("gpt-4o-mini") + self.edit_llm_model.setPlaceholderText(DEFAULTS["llm_model"]) self.combo_tone = QComboBox() self.combo_tone.addItems(["formal", "neutral", "locker"]) diff --git a/app/llm_service.py b/app/llm_service.py index c7d7a82..d308aae 100644 --- a/app/llm_service.py +++ b/app/llm_service.py @@ -4,13 +4,14 @@ import logging from typing import Any, Optional +from app.config import DEFAULTS from app.workflows import WorkflowType from app.writing_presets import DEFAULT_PRESET_KEY, get_preset logger = logging.getLogger("blitztext.llm_service") LLM_WORKFLOWS = {WorkflowType.TEXT_IMPROVER, WorkflowType.DAMPF_ABLASSEN, WorkflowType.EMOJI_TEXT} -MODEL = "gpt-4o-mini" +DEFAULT_LLM_MODEL = DEFAULTS["llm_model"] _DAMPF_SYSTEM = ( "Du erhältst ein emotional gesprochenes Transkript. Erkenne zuerst das eigentliche " @@ -73,7 +74,7 @@ def __init__( self.api_key = api_key or "" self.api_key_env = api_key_env or "OPENAI_API_KEY" self.base_url = (base_url or "").strip() - self.model = (model or "").strip() or MODEL + self.model = (model or "").strip() or DEFAULT_LLM_MODEL self.tone = tone self.emoji_density = emoji_density self.dampf_system_prompt = dampf_system_prompt diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index 4af68a4..1edb856 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -6,6 +6,7 @@ import pytest +from app.config import DEFAULTS from app.llm_service import LLMService, LLMServiceError, _NullLLMClient from app.workflows import WorkflowType from app.writing_presets import WRITING_PRESETS @@ -58,8 +59,8 @@ def test_empty_api_key_uses_null_client_instead_of_mock(self): service = LLMService(api_key="", base_url="") assert isinstance(service.client, _NullLLMClient) - def test_default_model_is_gpt_4o_mini(self, service): - assert service.model == "gpt-4o-mini" + def test_default_model_comes_from_config_defaults(self, service): + assert service.model == DEFAULTS["llm_model"] def test_custom_model_is_used_in_requests(self, mock_client): service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, model="openai/gpt-4o") @@ -67,9 +68,9 @@ def test_custom_model_is_used_in_requests(self, mock_client): kwargs = mock_client.chat.completions.create.call_args.kwargs assert kwargs["model"] == "openai/gpt-4o" - def test_empty_model_falls_back_to_default(self, mock_client): + def test_empty_model_falls_back_to_config_default(self, mock_client): service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, model="") - assert service.model == "gpt-4o-mini" + assert service.model == DEFAULTS["llm_model"] def test_base_url_stored_on_service(self, mock_client): service = LLMService(api_key=DUMMY_API_KEY, client=mock_client, base_url="https://openrouter.ai/api/v1") From 20ac12064fb9a1e80459565997e2511f14e9fbbb Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sun, 21 Jun 2026 02:48:32 +0200 Subject: [PATCH 5/7] docs(roadmap): mark h7 as in progress --- ROADMAP.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2e52172..cb3f57f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,8 +58,8 @@ Phase 2 — optional cleanups (LOW): Phase 3 — only if desired / verify first: - [ ] H6 Make `paste_service._KEY_DELAY_MS` configurable via config. -- [ ] H7 `transcribe._transcribe_openai`: set `CUDA_VISIBLE_DEVICES` locally via - `subprocess(env=...)` instead of mutating the global process env. +- [~] H7 `transcribe._transcribe_openai`: set `CUDA_VISIBLE_DEVICES` locally via + `subprocess(env=...)` instead of mutating the global process env. *(in Bearbeitung)* - [x] H8 Collapse the duplicate default model source (`llm_service.MODEL` vs `config.py` default) to a single source of truth. - [x] H9 Verify (do not blindly remove) the `tts_openai_voice` validation set: From 09ce72fd18414b3702f684cd056cb1fb75dd3c21 Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sun, 21 Jun 2026 04:04:52 +0200 Subject: [PATCH 6/7] fix(audit): localize openai whisper cuda override --- ROADMAP.md | 4 ++-- app/transcribe.py | 25 +++++++++++++++++-------- tests/test_transcribe.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cb3f57f..dd6856c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,8 +58,8 @@ Phase 2 — optional cleanups (LOW): Phase 3 — only if desired / verify first: - [ ] H6 Make `paste_service._KEY_DELAY_MS` configurable via config. -- [~] H7 `transcribe._transcribe_openai`: set `CUDA_VISIBLE_DEVICES` locally via - `subprocess(env=...)` instead of mutating the global process env. *(in Bearbeitung)* +- [x] H7 `transcribe._transcribe_openai`: avoid leaking `CUDA_VISIBLE_DEVICES` + mutations beyond the OpenAI Whisper call; local override restored after use. - [x] H8 Collapse the duplicate default model source (`llm_service.MODEL` vs `config.py` default) to a single source of truth. - [x] H9 Verify (do not blindly remove) the `tts_openai_voice` validation set: diff --git a/app/transcribe.py b/app/transcribe.py index bd49147..6dc8e55 100644 --- a/app/transcribe.py +++ b/app/transcribe.py @@ -150,16 +150,25 @@ def _load_faster_whisper_model_class(): def _transcribe_openai(wav_file: str, model_name: str, language: str, hint: str | None = None) -> str: warnings.filterwarnings("ignore", message="FP16 is not supported on CPU") - if _should_force_cpu_for_openai(): + original_cuda_visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES") + should_force_cpu = _should_force_cpu_for_openai() + if should_force_cpu: os.environ["CUDA_VISIBLE_DEVICES"] = "" - whisper = _load_openai_whisper_module() + try: + whisper = _load_openai_whisper_module() - model = whisper.load_model(model_name) - result = model.transcribe( - wav_file, - language=_normalize_language(language), - initial_prompt=hint, - ) + model = whisper.load_model(model_name) + result = model.transcribe( + wav_file, + language=_normalize_language(language), + initial_prompt=hint, + ) + finally: + if should_force_cpu: + if original_cuda_visible_devices is None: + os.environ.pop("CUDA_VISIBLE_DEVICES", None) + else: + os.environ["CUDA_VISIBLE_DEVICES"] = original_cuda_visible_devices if not isinstance(result, dict): logger.warning("Transcription result is not a dict: %r", type(result).__name__) return "" diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index df88315..3e361bc 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from pathlib import Path from unittest.mock import patch @@ -17,3 +18,35 @@ def test_transcribe_resolves_path_before_backend_call(tmp_path: Path): assert result == "OK" resolve_mock.assert_called_once() backend_mock.assert_called_once() + + +def test_transcribe_openai_cpu_override_is_local_to_the_call(tmp_path: Path, monkeypatch): + wav_file = tmp_path / "sample.wav" + wav_file.write_bytes(b"RIFFdata") + monkeypatch.delenv("WHISPER_USE_CUDA", raising=False) + monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "7") + + observed = {} + + class FakeModel: + def transcribe(self, *_args, **_kwargs): + observed["during_transcribe"] = os.environ.get("CUDA_VISIBLE_DEVICES") + return {"text": " Hallo "} + + fake_model = FakeModel() + + class FakeWhisperModule: + @staticmethod + def load_model(_model_name): + observed["during_load_model"] = os.environ.get("CUDA_VISIBLE_DEVICES") + return fake_model + + with patch("app.transcribe._load_openai_whisper_module", return_value=FakeWhisperModule()): + result = transcribe(wav_file, backend="openai-whisper") + + assert result == "Hallo" + assert observed == { + "during_load_model": "", + "during_transcribe": "", + } + assert os.environ.get("CUDA_VISIBLE_DEVICES") == "7" From c4fc62e31eec3e4182046c0c05345bd5125ef82c Mon Sep 17 00:00:00 2001 From: gummiflip Date: Sun, 21 Jun 2026 04:15:03 +0200 Subject: [PATCH 7/7] feat(audit): make paste key delay configurable --- ROADMAP.md | 2 +- app/blitztext_linux.py | 3 ++- app/config.py | 14 ++++++++++++++ app/paste_service.py | 6 ++++-- tests/test_features.py | 3 +++ tests/test_settings_dialog.py | 4 +++- tests/test_state_machine.py | 15 +++++++++++++++ 7 files changed, 42 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index dd6856c..28dc354 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,7 +57,7 @@ Phase 2 — optional cleanups (LOW): Phase 3 — only if desired / verify first: -- [ ] H6 Make `paste_service._KEY_DELAY_MS` configurable via config. +- [x] H6 Make `PasteService` key delay configurable via config and wire it through app init/runtime updates. - [x] H7 `transcribe._transcribe_openai`: avoid leaking `CUDA_VISIBLE_DEVICES` mutations beyond the OpenAI Whisper call; local override restored after use. - [x] H8 Collapse the duplicate default model source (`llm_service.MODEL` vs diff --git a/app/blitztext_linux.py b/app/blitztext_linux.py index 4f99acd..d06e4d7 100644 --- a/app/blitztext_linux.py +++ b/app/blitztext_linux.py @@ -604,7 +604,7 @@ def __init__(self, app: QApplication) -> None: self.llm_service = self._build_llm_service() self.audio_recorder = AudioRecorder() - self.paste_service = PasteService(autopaste=self.config.autopaste) + self.paste_service = PasteService(autopaste=self.config.autopaste, key_delay_ms=self.config.paste_key_delay_ms) # State machine state: "IDLE", "RECORDING", "TRANSCRIBING", "LLM_REWRITING" self.state = "IDLE" @@ -964,6 +964,7 @@ def _stop_recording_and_process(self) -> None: # Ensure PasteService has the latest autopaste configuration self.paste_service.autopaste = self.config.autopaste + self.paste_service.key_delay_ms = self.config.paste_key_delay_ms # Create the transcribe worker worker = _TranscribeWorker( diff --git a/app/config.py b/app/config.py index e3d688c..727f313 100644 --- a/app/config.py +++ b/app/config.py @@ -31,6 +31,7 @@ "llm_base_url": "", "llm_model": "gpt-4o-mini", "autopaste": True, + "paste_key_delay_ms": 80, "audio_device": "@DEFAULT_SOURCE@", "notes_folder": str(Path.home() / "Blitztext-Notizen"), "history_size": 50, @@ -256,6 +257,19 @@ def autopaste(self) -> bool: def autopaste(self, value: bool) -> None: self._data["autopaste"] = bool(value) + @property + def paste_key_delay_ms(self) -> int: + raw = self._data.get("paste_key_delay_ms", DEFAULTS["paste_key_delay_ms"]) + try: + value = int(raw) + except (TypeError, ValueError): + return DEFAULTS["paste_key_delay_ms"] + return max(0, min(1000, value)) + + @paste_key_delay_ms.setter + def paste_key_delay_ms(self, value: int) -> None: + self._data["paste_key_delay_ms"] = max(0, min(1000, int(value))) + @property def audio_device(self) -> str: return self._data.get("audio_device", "@DEFAULT_SOURCE@") diff --git a/app/paste_service.py b/app/paste_service.py index 0dae3d9..783e5fc 100644 --- a/app/paste_service.py +++ b/app/paste_service.py @@ -56,12 +56,14 @@ class PasteService: svc.paste("Hallo Welt") """ - def __init__(self, autopaste: bool = True) -> None: + def __init__(self, autopaste: bool = True, key_delay_ms: int = _KEY_DELAY_MS) -> None: """ Args: autopaste: True = nach wl-copy automatisch Ctrl+V via ydotool senden. + key_delay_ms: Verzögerung zwischen ydotool-Keyevents in Millisekunden. """ self.autopaste = autopaste + self.key_delay_ms = max(0, int(key_delay_ms)) def paste(self, text: str) -> None: """Text ins Clipboard schreiben und optional einfuegen. @@ -154,7 +156,7 @@ def _ydotool_paste(self) -> None: time.sleep(_PASTE_DELAY) try: result = subprocess.run( - ["ydotool", "key", "--key-delay", str(_KEY_DELAY_MS), *_CTRL_V_KEYCODES], + ["ydotool", "key", "--key-delay", str(self.key_delay_ms), *_CTRL_V_KEYCODES], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, diff --git a/tests/test_features.py b/tests/test_features.py index 2378808..eed8053 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -35,6 +35,7 @@ def test_defaults(self, tmp_path): assert cfg.tts_openai_model == "gpt-4o-mini-tts" assert cfg.tts_openai_voice == "marin" assert cfg.notes_folder.endswith("Blitztext-Notizen") + assert cfg.paste_key_delay_ms == 80 def test_history_size_clamped(self, tmp_path): cfg = Config.load(tmp_path / "config.json") @@ -59,6 +60,7 @@ def test_roundtrip_save_load(self, tmp_path): cfg.tts_openai_voice = "nova" cfg.notes_folder = str(Path.home() / "Notizen") cfg.history_size = 25 + cfg.paste_key_delay_ms = 135 cfg.save() cfg2 = Config.load(path) assert cfg2.tts_voice == "de_DE-thorsten-medium.onnx" @@ -67,6 +69,7 @@ def test_roundtrip_save_load(self, tmp_path): assert cfg2.tts_openai_voice == "nova" assert cfg2.history_size == 25 assert cfg2.notes_folder.endswith("Notizen") + assert cfg2.paste_key_delay_ms == 135 def test_sanitize_bad_values(self, tmp_path): path = tmp_path / "config.json" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index b4a06e1..b5501a5 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -204,6 +204,7 @@ def test_app_init_applies_configured_ui_language(tmp_path): config = BlitztextConfig(config_dir=tmp_path / ".config" / "blitztext-linux") config.ui_language = "en" + config.paste_key_delay_ms = 135 fake_qapp = Mock() try: @@ -212,10 +213,11 @@ def test_app_init_applies_configured_ui_language(tmp_path): patch.object(BlitztextApp, "setup_tray"), \ patch.object(BlitztextApp, "start_hotkey_worker"), \ patch("app.blitztext_linux.AudioRecorder"), \ - patch("app.blitztext_linux.PasteService"): + patch("app.blitztext_linux.PasteService") as paste_service_cls: BlitztextApp(fake_qapp) assert get_language() == "en" + paste_service_cls.assert_called_once_with(autopaste=config.autopaste, key_delay_ms=135) finally: set_language(DEFAULT_LANGUAGE) diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py index b886e2c..a3d34ec 100644 --- a/tests/test_state_machine.py +++ b/tests/test_state_machine.py @@ -60,6 +60,21 @@ def test_paste_passes_timeout_to_subprocess(self): svc.paste("text") assert run_mock.call_args.kwargs.get("timeout") is not None + def test_paste_uses_configured_key_delay_for_ydotool(self): + svc = PasteService(autopaste=True, key_delay_ms=135) + with patch("app.paste_service.shutil.which", return_value="/usr/bin/tool"), \ + patch("app.paste_service.time.sleep"), \ + patch("app.paste_service.subprocess.run") as run_mock: + def side_effect(cmd, *args, **kwargs): + if cmd[0] == "wl-copy": + return subprocess.CompletedProcess(cmd, 0, b"", b"") + return subprocess.CompletedProcess(cmd, 0, b"", b"") + run_mock.side_effect = side_effect + svc.paste("hallo welt") + + ydotool_call = run_mock.call_args_list[1] + assert ydotool_call.args[0][:4] == ["ydotool", "key", "--key-delay", "135"] + def test_paste_missing_ydotoold_does_not_raise(self, caplog): svc = PasteService(autopaste=True) caplog.set_level(logging.WARNING, logger="blitztext.paste_service")