From e6e8f16f5573f43500171d9027da8043e4dd5633 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:29:51 +0530 Subject: [PATCH 01/11] fix: guard voice conversion with active model cloning capability --- CHANGELOG.md | 2 + backend/api/routers/engines.py | 3 ++ docs/install/troubleshooting.md | 8 ++++ .../src/features/tools/convert-voice.test.tsx | 47 ++++++++++++++++++- .../src/features/tools/convert-voice.tsx | 20 +++++++- .../src/renderer/src/i18n/locales/ar.json | 1 + .../src/renderer/src/i18n/locales/de.json | 1 + .../src/renderer/src/i18n/locales/en.json | 1 + .../src/renderer/src/i18n/locales/es.json | 1 + .../src/renderer/src/i18n/locales/fr.json | 1 + .../src/renderer/src/i18n/locales/hi.json | 1 + .../src/renderer/src/i18n/locales/id.json | 1 + .../src/renderer/src/i18n/locales/it.json | 1 + .../src/renderer/src/i18n/locales/ja.json | 1 + .../src/renderer/src/i18n/locales/ko.json | 1 + .../src/renderer/src/i18n/locales/nl.json | 1 + .../src/renderer/src/i18n/locales/pl.json | 1 + .../src/renderer/src/i18n/locales/pt.json | 1 + .../src/renderer/src/i18n/locales/ru.json | 1 + .../src/renderer/src/i18n/locales/sv.json | 1 + .../src/renderer/src/i18n/locales/th.json | 1 + .../src/renderer/src/i18n/locales/tr.json | 1 + .../src/renderer/src/i18n/locales/uk.json | 1 + .../src/renderer/src/i18n/locales/vi.json | 1 + .../src/renderer/src/i18n/locales/zh-CN.json | 1 + .../src/renderer/src/i18n/locales/zh-TW.json | 1 + frontend/src/i18n/locales/ar.json | 1 + frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/es.json | 1 + frontend/src/i18n/locales/fr.json | 1 + frontend/src/i18n/locales/hi.json | 1 + frontend/src/i18n/locales/id.json | 1 + frontend/src/i18n/locales/it.json | 1 + frontend/src/i18n/locales/ja.json | 1 + frontend/src/i18n/locales/ko.json | 1 + frontend/src/i18n/locales/nl.json | 1 + frontend/src/i18n/locales/pl.json | 1 + frontend/src/i18n/locales/pt.json | 1 + frontend/src/i18n/locales/ru.json | 1 + frontend/src/i18n/locales/sv.json | 1 + frontend/src/i18n/locales/th.json | 1 + frontend/src/i18n/locales/tr.json | 1 + frontend/src/i18n/locales/uk.json | 1 + frontend/src/i18n/locales/vi.json | 1 + frontend/src/i18n/locales/zh-CN.json | 1 + frontend/src/i18n/locales/zh-TW.json | 1 + tests/test_active_cloning_capability.py | 15 ++++++ 48 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 tests/test_active_cloning_capability.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3301b96e..c17f3943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- Check active model cloning support before starting voice conversion (#2147) + - Repair CTranslate2 loading safely across ASR and translation, and retain the loaded Whisper model during CPU fallback (#2165) — thanks @guruthechosen! - Avoid pedalboard wheels that crash on unsupported CPU instructions (#2080) — thanks @D3nii! - Include cuDNN 8 compatibility libraries for CTranslate2 in CUDA containers (#2072) — thanks @basil-k-aji-dev! diff --git a/backend/api/routers/engines.py b/backend/api/routers/engines.py index cd689cc4..88a14658 100644 --- a/backend/api/routers/engines.py +++ b/backend/api/routers/engines.py @@ -85,6 +85,9 @@ def _family_payload(family: str, module): for backend in backends: engine_id = backend.get("id") + if engine_id == active == "mlx-audio": + # Constructor resolves model preferences only; never loads weights. + backend["supports_cloning"] = tts_backend.MLXAudioBackend().supports_cloning if engine_id in LICENSE_GATED_ENGINES: backend["license_required"] = True try: diff --git a/docs/install/troubleshooting.md b/docs/install/troubleshooting.md index 7d0a8d69..2f91c431 100644 --- a/docs/install/troubleshooting.md +++ b/docs/install/troubleshooting.md @@ -1203,3 +1203,11 @@ Sidecar and audio.cpp runtime installation is restricted to requests from the ba Extraction errors show the FFmpeg exit code and the end of its diagnostics, with private paths scrubbed. Use the final error line to distinguish missing audio streams, unsupported inputs, permissions, or disk errors. A version banner alone does not identify the cause; include the final diagnostic and source format when reporting a failure. Explicit generation budgets remain authoritative. If an outer TTS/ASR guard times out or its caller disconnects, the active sidecar receive kills and reaps its captured child; it cannot terminate a later retry. In-process inference keeps its existing lifetime accounting until the native call returns. + +### Voice conversion requires a cloning model + +In Electron, voice conversion stays disabled until the active text-to-speech +model is ready and supports voice cloning. Use the Models link to choose one; +the source recording and target voice are preserved when returning. Preset-only +models such as MLX Kokoro cannot clone a target voice. This capability check does +not download or load model weights. diff --git a/electron/src/renderer/src/features/tools/convert-voice.test.tsx b/electron/src/renderer/src/features/tools/convert-voice.test.tsx index 7a04aa2f..6658e8df 100644 --- a/electron/src/renderer/src/features/tools/convert-voice.test.tsx +++ b/electron/src/renderer/src/features/tools/convert-voice.test.tsx @@ -2,7 +2,16 @@ import { clearConversion } from './conversion-state'; import { cleanup, fireEvent, render, screen, act } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { afterEach, expect, it, vi } from 'vitest'; -const mock = vi.hoisted(() => ({ convert: vi.fn() })); +const mock = vi.hoisted(() => ({ convert: vi.fn(), ready: true, cloning: true as boolean | null })); +vi.mock('@/hooks/use-engines', () => ({ + useEngines: () => ({ + activeTtsReady: mock.ready, + activeTts: { supports_cloning: mock.cloning }, + }), +})); +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children }: { children: React.ReactNode }) => {children}, +})); vi.mock('@/lib/api/convert', () => ({ convertSpeech: mock.convert })); vi.mock('@/hooks/use-recording', () => ({ useRecording: () => ({ isRecording: false, isStarting: false, isCleaning: false }), @@ -22,6 +31,8 @@ import { ConvertVoice } from './convert-voice'; afterEach(() => { cleanup(); clearConversion(); + mock.ready = true; + mock.cloning = true; vi.clearAllMocks(); }); function mount() { @@ -79,3 +90,37 @@ it('retains source and target when returning from model settings', () => { expect(screen.getByRole('button', { name: 'source.wav' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'convert.convert' })).toBeEnabled(); }); + +it.each([false, null])( + 'blocks conversion for unsupported or unknown cloning capability (%s)', + (capability) => { + mock.cloning = capability; + const { upload } = mount(); + upload(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha' })); + const button = screen.getByRole('button', { name: 'convert.convert' }); + expect(button).toBeDisabled(); + expect(screen.getByText('convert.cloning_required')).toBeInTheDocument(); + fireEvent.click(button); + expect(mock.convert).not.toHaveBeenCalled(); + }, +); +it('rechecks engine capability when returning from settings without losing inputs', () => { + const first = mount(); + first.upload(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha' })); + first.unmount(); + mock.cloning = false; + const second = mount(); + expect(screen.getByRole('button', { name: 'convert.convert' })).toBeDisabled(); + second.unmount(); + mock.cloning = true; + mock.ready = false; + const third = mount(); + expect(screen.getByRole('button', { name: 'convert.convert' })).toBeDisabled(); + third.unmount(); + mock.ready = true; + mount(); + expect(screen.getByRole('button', { name: 'convert.convert' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'source.wav' })).toBeInTheDocument(); +}); diff --git a/electron/src/renderer/src/features/tools/convert-voice.tsx b/electron/src/renderer/src/features/tools/convert-voice.tsx index 6db336e2..fe2b5f6d 100644 --- a/electron/src/renderer/src/features/tools/convert-voice.tsx +++ b/electron/src/renderer/src/features/tools/convert-voice.tsx @@ -11,6 +11,7 @@ import { PipelineFailure } from '@/components/pipeline-failure'; import { AgentFixButton } from '@/components/agent-fix-button'; import { ProfileAvatar } from '@/components/profile-avatar'; import { WaveformPlayer } from '@/components/waveform-player'; +import { useEngines } from '@/hooks/use-engines'; import { useProfiles } from '@/hooks/use-profiles'; import { useRecording } from '@/hooks/use-recording'; import { ApiError, apiPath, describeError } from '@/lib/api/client'; @@ -21,6 +22,8 @@ import { beginAppActivity } from '@/lib/app-activity'; export function ConvertVoice() { const { t } = useTranslation(); const profiles = useProfiles(); + const engines = useEngines(); + const canClone = engines.activeTtsReady && engines.activeTts?.supports_cloning === true; const client = useQueryClient(); const { file, voice, search, match, result } = useConversion(); const setFile = (value: File | null) => setConversion('file', value); @@ -64,7 +67,7 @@ export function ConvertVoice() { }, [file]); useEffect(() => () => request.current?.abort(), []); const voices = (profiles.data ?? []).filter((p) => p.kind === 'clone' && p.ref_audio_path); - const valid = file && voices.some((p) => p.id === voice) && !recordingBusy; + const valid = canClone && file && voices.some((p) => p.id === voice) && !recordingBusy; const run = async () => { if (!valid || request.current) return; const controller = new AbortController(); @@ -210,6 +213,21 @@ export function ConvertVoice() {

{t('convert.match_duration_hint')}

+ {!canClone && !busy && ( +
+ {t('convert.cloning_required')} + + {t('modelSettings.models')} + +
+ )} {error && ( Date: Thu, 17 Sep 2026 16:50:16 +0530 Subject: [PATCH 02/11] fix(setup): send the HF token when installing a gated model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing a gated model failed the fast download path with "401 Unauthorized" even with a valid token and the licence accepted, then fell back to snapshot_download and logged a 401 that reads like the token or the licence grant is at fault when it is neither (#2163). `token_resolver.resolve()` returns a ResolvedToken record, not the bearer string. Two call sites handed that record straight to consumers typed `token: str | None`, and both fail silently rather than loudly: - `_segmented_snapshot` passes it to HfApi, get_hf_file_metadata and our own segmented_download. huggingface_hub's build_hf_headers ignores a non-str token and falls back to its own ambient discovery, so a token held only in VoiceStudio's Settings produces NO Authorization header and every gated file 401s. segmented_download instead interpolates it into `f"Bearer {token}"`, sending a malformed header that also inlines the raw secret into the request. - `_step_fetch_weights` passes it to snapshot_download, so gated engine weights 401 the same way. Every other resolve() caller already unwraps `.token`; these two were the outliers. Both now unwrap once, at the seam. The existing weights tests all stubbed resolve() to return None, so no test ever exercised a resolved token — which is why this went unnoticed. The new tests drive a real ResolvedToken through both seams and assert a `str` reaches every consumer, plus an integration test that installs the pyannote diarisation pipeline end to end: a weightless config_only repo validates, both dependency repos are fetched, and every call carries the bearer string. Two catalogue invariants keep the rest of #2163 from returning by edit: dependency repos must be revision-pinned (revision_for raises otherwise, so an unpinned one ships an always-failing install), and a config_only entry must declare config_required_files (without them the completeness check can never pass and the error lists no files at all — the shape the report hit on 0.5.2). Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + backend/api/routers/setup/download.py | 11 +- backend/services/sidecar_install.py | 7 +- tests/test_gated_install_token_2163.py | 134 ++++++++++++++++++++++ tests/test_hf_revisions.py | 13 ++- tests/test_models_catalog.py | 30 +++++ tests/test_pyannote_install_2163.py | 151 +++++++++++++++++++++++++ tests/test_sidecar_install.py | 58 ++++++++++ 8 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 tests/test_gated_install_token_2163.py create mode 100644 tests/test_pyannote_install_2163.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e2dd83..27d2b4d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ the frozen-backend fallback mirror it for their toolchains. - Transcribing an M4A file with PyTorch Whisper works, instead of failing with "Format not recognised" (#2042, #2039) - PyTorch Whisper runs on 6 GB NVIDIA cards instead of falling back to CPU, because its memory check now fits the model it loads (#2044, #2041) - MCP tools wait as long as the backend does, so a long transcription no longer fails at 120 s with an empty error (#2043, #2040) +- Installing a gated model now sends your Hugging Face token on the fast download path too, so pyannote diarisation and gated engine weights stop failing with "401 Unauthorized" when the token is saved in Settings (#2173, #2163) ### CI diff --git a/backend/api/routers/setup/download.py b/backend/api/routers/setup/download.py index 74ad664d..32f81d20 100644 --- a/backend/api/routers/setup/download.py +++ b/backend/api/routers/setup/download.py @@ -230,7 +230,16 @@ def _segmented_snapshot(repo_id: str, *, endpoint: "str | None", revision: str) from services.segmented_download import segmented_download from services.token_resolver import resolve as _resolve_token - token = _resolve_token() + # `resolve()` returns a ResolvedToken record, not the bearer string, and + # every consumer below is typed `token: str | None`. Handing over the + # record fails silently rather than loudly (#2163): huggingface_hub's + # build_hf_headers ignores a non-str token and falls back to its own + # ambient discovery, so a token held only in VoiceStudio's settings sends + # NO Authorization header at all and every gated file 401s; our own + # segmented_download interpolates it into `f"Bearer {token}"` and sends a + # malformed header carrying the raw secret. Unwrap once, here. + _resolved = _resolve_token() + token = _resolved.token if _resolved else None api = HfApi(endpoint=endpoint, token=token) info = api.repo_info(repo_id, repo_type="model", revision=revision) commit = info.sha diff --git a/backend/services/sidecar_install.py b/backend/services/sidecar_install.py index efd3e655..2e6f26c5 100644 --- a/backend/services/sidecar_install.py +++ b/backend/services/sidecar_install.py @@ -1543,10 +1543,15 @@ def _step_fetch_weights(spec: SidecarSpec, job: dict) -> None: # other model download in the app — see setup/download.py): the # source checkout is unpinned upstream `main` anyway, and hf_hub # checksum-verifies each artifact. Hence the B615 waiver below. + # Unwrap to the bearer string: snapshot_download takes `token: str | + # None`, and a ResolvedToken record is ignored in favour of ambient + # discovery, so gated engine weights 401 for a user whose token lives + # in VoiceStudio's settings rather than HF's own cache (#2163). + _resolved = resolve_token() kwargs: dict = { "repo_id": spec.weights_repo_id, "local_dir": str(wdir), - "token": resolve_token(), + "token": _resolved.token if _resolved else None, } if spec.weights_revision: kwargs["revision"] = spec.weights_revision diff --git a/tests/test_gated_install_token_2163.py b/tests/test_gated_install_token_2163.py new file mode 100644 index 00000000..8a062500 --- /dev/null +++ b/tests/test_gated_install_token_2163.py @@ -0,0 +1,134 @@ +"""#2163: a gated model install must actually send the HF bearer token. + +``token_resolver.resolve()`` returns a ``ResolvedToken`` *record*. Every +huggingface_hub entry point — and our own ``segmented_download`` — takes +``token: str | None``. The segmented accelerator handed the record straight +through, and neither consumer complains: + +* ``build_hf_headers`` ignores a non-``str`` token and falls back to + huggingface_hub's own ambient discovery, so a token held only in + VoiceStudio's Settings produces **no** ``Authorization`` header at all and + every gated file 401s; +* ``segmented_download`` interpolates it into ``f"Bearer {token}"``, sending a + malformed header that also inlines the raw secret into the request. + +Either way the accelerator 401s on the first file of a gated repo, is disabled +for the rest of the install, and logs a 401 that reads like the user's token or +license grant is at fault when it is neither. +""" +import importlib +import os +from types import SimpleNamespace + +os.environ.setdefault("OMNIVOICE_MODEL", "test") +os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1") + +import pytest + +from services.token_resolver import ResolvedToken + + +@pytest.fixture +def download(): + return importlib.import_module("api.routers.setup.download") + + +GATED_REPO = "pyannote/speaker-diarization-3.1" +REVISION = "c" * 40 + + +def _drive_segmented(download, monkeypatch, tmp_path, resolved): + """Run ``_segmented_snapshot`` with every network seam mocked. + + Returns the token value each of the three consumers actually received. + """ + import huggingface_hub + from huggingface_hub import file_download as hf_file_download + from services import segmented_download as sd_mod + from services import token_resolver + + monkeypatch.setattr(token_resolver, "resolve", lambda *a, **k: resolved) + monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_CACHE", str(tmp_path)) + + seen: dict = {} + + class _FakeApi: + def __init__(self, *, endpoint=None, token=None): + seen["hf_api"] = token + + def repo_info(self, repo_id, repo_type=None, revision=None): + # Gated repos serve metadata unauthenticated and gate the file + # bytes — which is why the 401 in #2163 lands on the first + # resolve() call rather than here. + return SimpleNamespace( + sha=revision, + siblings=[SimpleNamespace(rfilename="config.yaml")], + ) + + monkeypatch.setattr(huggingface_hub, "HfApi", _FakeApi) + + def _fake_metadata(url, token=None, **_kw): + seen["file_metadata"] = token + return SimpleNamespace(etag='"deadbeef"', location=url, size=10) + + monkeypatch.setattr(hf_file_download, "get_hf_file_metadata", _fake_metadata) + + async def _fake_segmented(url, blob_path, *, token=None, **_kw): + seen["segmented"] = token + with open(blob_path, "wb") as fh: + fh.write(b"config: ok") + return blob_path + + monkeypatch.setattr(sd_mod, "segmented_download", _fake_segmented) + + download._segmented_snapshot(GATED_REPO, endpoint=None, revision=REVISION) + return seen + + +def test_segmented_install_sends_the_bearer_string_to_every_consumer( + download, monkeypatch, tmp_path +): + seen = _drive_segmented( + download, + monkeypatch, + tmp_path, + ResolvedToken(token="hf_gatedsecret", source="app", username="tester"), + ) + + assert seen == { + "hf_api": "hf_gatedsecret", + "file_metadata": "hf_gatedsecret", + "segmented": "hf_gatedsecret", + } + # The record itself must never cross the seam — that is the whole bug. + for consumer, value in seen.items(): + assert isinstance(value, str), f"{consumer} received {type(value).__name__}" + + +def test_segmented_install_sends_no_token_when_none_resolves( + download, monkeypatch, tmp_path +): + # No token anywhere: every consumer must get a real None so huggingface_hub + # treats the repo as anonymous, never the string "None". + seen = _drive_segmented(download, monkeypatch, tmp_path, None) + assert seen == {"hf_api": None, "file_metadata": None, "segmented": None} + + +def test_a_token_record_would_build_a_broken_authorization_header(): + """Why the unwrap matters, pinned at our own auth seam. + + ``_auth_headers`` is the function that turns the token into the header the + segmented downloader sends. Given the bearer string it produces a valid + header; given the record it produces a malformed one that also inlines the + raw secret. This is the failure #2163 reported as a 401. + """ + from services.segmented_download import _auth_headers + + url = "https://huggingface.co/pyannote/speaker-diarization-3.1/resolve/main/config.yaml" + record = ResolvedToken(token="hf_gatedsecret", source="app", username="tester") + + assert _auth_headers(url, record.token) == {"Authorization": "Bearer hf_gatedsecret"} + + broken = _auth_headers(url, record)["Authorization"] + assert broken != "Bearer hf_gatedsecret" + assert "ResolvedToken" in broken diff --git a/tests/test_hf_revisions.py b/tests/test_hf_revisions.py index 93b4d2c6..2a09bcd9 100644 --- a/tests/test_hf_revisions.py +++ b/tests/test_hf_revisions.py @@ -8,10 +8,17 @@ from services import hf_revisions def test_every_catalog_repo_has_an_immutable_revision(): catalog = yaml.safe_load(Path("backend/config/models.yaml").read_text(encoding="utf-8")) + # Dependency repos are downloaded by the installer exactly like top-level + # ones (setup/download.py resolves `revision_for(dependency["repo_id"])`), + # and `revision_for` raises on an unpinned repo — so an unpinned dependency + # ships an install that always fails. Pin them under the same rule (#2163). + curated = [] + for model in catalog["models"]: + curated.append(model["repo_id"]) + for dependency in model.get("dependencies") or (): + curated.append(dependency["repo_id"]) missing = { - model["repo_id"] - for model in catalog["models"] - if model["repo_id"] not in hf_revisions.CURATED_REVISIONS + repo_id for repo_id in curated if repo_id not in hf_revisions.CURATED_REVISIONS } assert missing == set() assert all(len(revision) == 40 for revision in hf_revisions.CURATED_REVISIONS.values()) diff --git a/tests/test_models_catalog.py b/tests/test_models_catalog.py index f61bf9de..19055115 100644 --- a/tests/test_models_catalog.py +++ b/tests/test_models_catalog.py @@ -43,6 +43,36 @@ def test_every_repo_id_is_well_formed(): assert rid and _REPO_RE.match(rid), f"malformed repo_id: {rid!r}" +def test_config_only_entries_declare_the_files_that_complete_them(): + """A ``config_only`` repo carries no weights of its own, so the install + validator judges it by ``config_required_files`` instead of a weight floor. + Declare none and the entry is permanently uninstallable: the completeness + check returns False and the install fails with a message whose list of + required files is empty. That is the shape #2163 reported, so it is a + catalogue invariant rather than something a user should discover. + """ + for m in _models(): + if not m.get("config_only"): + continue + required = m.get("config_required_files") + assert required, f"{m['repo_id']}: config_only needs config_required_files" + assert all( + isinstance(name, str) and name.strip() for name in required + ), f"{m['repo_id']}: blank entry in config_required_files" + + +def test_dependency_declarations_are_installable(): + """Every declared dependency needs an id and the files that prove it landed + — the installer rejects a dependency snapshot that lacks them.""" + for m in _models(): + for dependency in m.get("dependencies") or (): + rid = dependency.get("repo_id") + assert rid and _REPO_RE.match(rid), f"malformed dependency repo_id: {rid!r}" + assert dependency.get("required_files"), ( + f"{m['repo_id']} → {rid}: dependency needs required_files" + ) + + def test_required_fields_present(): for m in _models(): for field in ("repo_id", "label", "role"): diff --git a/tests/test_pyannote_install_2163.py b/tests/test_pyannote_install_2163.py new file mode 100644 index 00000000..d415c656 --- /dev/null +++ b/tests/test_pyannote_install_2163.py @@ -0,0 +1,151 @@ +"""#2163: installing the pyannote diarisation pipeline end to end. + +The pipeline repo (`pyannote/speaker-diarization-3.1`) carries a `config.yaml` +and no weights of its own — the real checkpoints live in the two repositories +its catalogue entry declares as `dependencies`. That shape exercises three +things at once, and the report hit all three: + +* the finished-snapshot validator must accept a weightless `config_only` repo + instead of rejecting it as a truncated download; +* both dependency repositories must actually be fetched, or the install + "succeeds" with nothing that can run; +* every download must carry the resolved HF bearer token **as a string**, since + the pipeline and segmentation repos are gated. + +This is the integration guard for the whole scenario; the token seam itself is +unit-tested in ``test_gated_install_token_2163.py``. +""" +import asyncio +import importlib +import os +from pathlib import Path + +os.environ.setdefault("OMNIVOICE_MODEL", "test") +os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1") + +import pytest + +from services.token_resolver import ResolvedToken + + +PIPELINE = "pyannote/speaker-diarization-3.1" +SEGMENTATION = "pyannote/segmentation-3.0" +EMBEDDING = "pyannote/wespeaker-voxceleb-resnet34-LM" + +_WEIGHT_BYTES = 6 * 1024 * 1024 # clears the 5 MB .bin floor in setup/models + + +@pytest.fixture +def download(): + return importlib.import_module("api.routers.setup.download") + + +def _install_pyannote(download, monkeypatch, tmp_path): + """Run POST /models/install for the pipeline repo with the Hub mocked. + + Returns (snapshot_download kwargs per call, emitted SSE events). + """ + import huggingface_hub + from services import hf_revisions, performance_profiles, token_resolver + from utils import hf_progress + + monkeypatch.setattr( + token_resolver, + "resolve", + lambda *a, **k: ResolvedToken( + token="hf_gatedsecret", source="app", username="tester" + ), + ) + + calls: list[dict] = [] + + def fake_snapshot_download(**kwargs): + calls.append(kwargs) + if kwargs.get("dry_run"): + return [] + repo_id = kwargs["repo_id"] + path = tmp_path / repo_id.replace("/", "__") + path.mkdir(parents=True, exist_ok=True) + # Mirror the real repos: the pipeline ships only a config, each + # dependency ships a config plus its checkpoint. + (path / "config.yaml").write_text("pipeline: ok\n", encoding="utf-8") + if repo_id != PIPELINE: + (path / "pytorch_model.bin").write_bytes(b"\0" * _WEIGHT_BYTES) + return str(path) + + monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download) + monkeypatch.setattr(download, "compute_plan", lambda _plan: { + "total_bytes": 1, "cached_bytes": 0, "to_download_bytes": 1, + "n_files": 1, "n_cached": 0, + }) + monkeypatch.setattr(download, "disk_space_error", lambda *_a, **_k: None) + # Force the snapshot_download path so this test covers the install flow; + # the segmented accelerator has its own unit tests. + monkeypatch.setattr(download, "_segmented_enabled", lambda: False) + monkeypatch.setattr(hf_revisions, "remember_revision", lambda *_a: None) + monkeypatch.setattr(performance_profiles, "reconcile_active_profile", lambda: None) + + events: list[dict] = [] + listener_id = hf_progress.register_listener(lambda ev: events.append(ev)) + + async def _run(): + await download.install_model(download.InstallModelRequest(repo_id=PIPELINE)) + pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + if pending: + await asyncio.gather(*pending) + + try: + asyncio.run(_run()) + finally: + hf_progress.unregister_listener(listener_id) + download._install_cooldowns.pop(PIPELINE, None) + download._install_failures.pop(PIPELINE, None) + + return calls, events + + +def test_pyannote_pipeline_install_completes(download, monkeypatch, tmp_path): + calls, events = _install_pyannote(download, monkeypatch, tmp_path) + + phases = [e.get("phase") for e in events] + errors = [e for e in events if e.get("phase") == "install_error"] + assert not errors, f"install failed: {[e.get('error') for e in errors]}" + assert "install_done" in phases + + # A weightless pipeline repo is a valid install, not a truncated download — + # the "no model weights were found in the snapshot" rejection in the report. + assert PIPELINE not in str(errors) + + +def test_pyannote_install_fetches_both_dependency_repositories( + download, monkeypatch, tmp_path +): + calls, _events = _install_pyannote(download, monkeypatch, tmp_path) + + real = [c for c in calls if not c.get("dry_run")] + fetched = [c["repo_id"] for c in real] + assert fetched == [PIPELINE, SEGMENTATION, EMBEDDING], ( + "the pipeline config alone is not a runnable install" + ) + + # Each dependency is filtered to the files its catalogue entry declares. + by_repo = {c["repo_id"]: c for c in real} + for dependency in (SEGMENTATION, EMBEDDING): + assert by_repo[dependency]["allow_patterns"] == [ + "config.yaml", + "pytorch_model.bin", + ] + # The pipeline repo itself is unfiltered — it has no allow_patterns. + assert "allow_patterns" not in by_repo[PIPELINE] + + +def test_every_pyannote_download_carries_the_bearer_string( + download, monkeypatch, tmp_path +): + calls, _events = _install_pyannote(download, monkeypatch, tmp_path) + + assert calls, "no download was attempted" + for call in calls: + token = call.get("token") + assert token == "hf_gatedsecret", f"{call['repo_id']} sent {token!r}" + assert isinstance(token, str) diff --git a/tests/test_sidecar_install.py b/tests/test_sidecar_install.py index 96f903a5..c6ff7298 100644 --- a/tests/test_sidecar_install.py +++ b/tests/test_sidecar_install.py @@ -620,6 +620,64 @@ def test_weights_step_downloads_via_endpoint_autoselect(monkeypatch): assert si._weights_present(spec) +def test_weights_download_sends_the_bearer_string_not_the_token_record(monkeypatch): + """#2163: `token_resolver.resolve()` returns a ResolvedToken record, but + snapshot_download takes `token: str | None` and silently ignores a non-str + — falling back to huggingface_hub's own ambient token discovery. So gated + engine weights 401 for a user whose token lives in VoiceStudio's Settings + rather than HF's cache. Every other weights test here stubs resolve() to + None, which is exactly why this went unnoticed.""" + from services.token_resolver import ResolvedToken + + spec = _mk_spec(weights_repo_id="Example/Gated") + seen = {} + + def fake_snapshot_download(**kwargs): + seen.update(kwargs) + Path(kwargs["local_dir"]).mkdir(parents=True, exist_ok=True) + (Path(kwargs["local_dir"]) / "config.yaml").write_text("ok\n") + (Path(kwargs["local_dir"]) / "w.safetensors").write_bytes(b"\0" * (6 * 1024 * 1024)) + + import huggingface_hub + monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download) + monkeypatch.setattr("services.endpoint_race.effective_endpoint", lambda: None) + monkeypatch.setattr( + "services.token_resolver.resolve", + lambda: ResolvedToken(token="hf_gatedsecret", source="app", username="tester"), + ) + + job = si._new_job(spec.engine_id) + si._job_step(job, "fetch_weights")["state"] = "running" + si._step_fetch_weights(spec, job) + + assert seen["token"] == "hf_gatedsecret" + assert isinstance(seen["token"], str) + + +def test_weights_download_sends_no_token_when_none_resolves(monkeypatch): + # The other half of the contract: no token anywhere must reach + # snapshot_download as a real None, never the string "None". + spec = _mk_spec(weights_repo_id="Example/Open") + seen = {} + + def fake_snapshot_download(**kwargs): + seen.update(kwargs) + Path(kwargs["local_dir"]).mkdir(parents=True, exist_ok=True) + (Path(kwargs["local_dir"]) / "config.yaml").write_text("ok\n") + (Path(kwargs["local_dir"]) / "w.safetensors").write_bytes(b"\0" * (6 * 1024 * 1024)) + + import huggingface_hub + monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download) + monkeypatch.setattr("services.endpoint_race.effective_endpoint", lambda: None) + monkeypatch.setattr("services.token_resolver.resolve", lambda: None) + + job = si._new_job(spec.engine_id) + si._job_step(job, "fetch_weights")["state"] = "running" + si._step_fetch_weights(spec, job) + + assert seen["token"] is None + + def test_weights_revision_is_pinned_and_old_marker_forces_upgrade(monkeypatch): spec = _mk_spec( weights_repo_id="Example/Weights", From 834b305c05c6bb9ce69ea04457efa005226af1f8 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:11:38 +0530 Subject: [PATCH 03/11] fix(electron): validate reused Python runtimes before startup --- CHANGELOG.md | 2 ++ docs/electron-migration.md | 5 +++ electron/src/main/backend-setup.test.ts | 28 +++++++++++++++ electron/src/main/backend.ts | 29 +++++++++------- .../src/main/runtime-dependencies.test.ts | 34 +++++++++++++++++++ electron/src/main/runtime-project.ts | 24 +++++++++++++ 6 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 electron/src/main/runtime-dependencies.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3301b96e..06ae043c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- Validate Python dependencies before reusing a desktop runtime and offer setup for incomplete environments (#2176) + - Repair CTranslate2 loading safely across ASR and translation, and retain the loaded Whisper model during CPU fallback (#2165) — thanks @guruthechosen! - Avoid pedalboard wheels that crash on unsupported CPU instructions (#2080) — thanks @D3nii! - Include cuDNN 8 compatibility libraries for CTranslate2 in CUDA containers (#2072) — thanks @basil-k-aji-dev! diff --git a/docs/electron-migration.md b/docs/electron-migration.md index c2fcf035..72d9f64d 100644 --- a/docs/electron-migration.md +++ b/docs/electron-migration.md @@ -18,3 +18,8 @@ permissions. No automatic installer-to-installer migration is provided. The final Tauri updater feeds retain signed Tauri payloads at immutable URLs. A Tauri updater must never receive an Electron installer. + +Electron checks required Python imports before reusing an existing runtime. An +incomplete environment opens setup instead of repeatedly crashing; installation +still requires your explicit action. Automatic selection skips broken legacy +runtimes and uses the Electron runtime location, leaving Tauri data intact. diff --git a/electron/src/main/backend-setup.test.ts b/electron/src/main/backend-setup.test.ts index 7085da1c..0e8a0887 100644 --- a/electron/src/main/backend-setup.test.ts +++ b/electron/src/main/backend-setup.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from 'vitest'; import { EventEmitter } from 'node:events'; const mocks = vi.hoisted(() => ({ + dependencies: vi.fn(async () => true), ready: vi.fn(async () => false), compatible: vi.fn(async () => false), interrupted: vi.fn(async () => false), @@ -15,6 +16,7 @@ vi.mock('electron', () => ({ app: { isPackaged: true, getPath: () => '/private/v vi.mock('node:child_process', () => ({ spawn: mocks.spawn, spawnSync: vi.fn() })); vi.mock('node:fs/promises', () => ({ rm: mocks.rm })); vi.mock('./runtime-project', () => ({ + runtimeDependenciesReady: mocks.dependencies, runtimeReady: mocks.ready, runtimeCompatible: mocks.compatible, runtimeInstallInterrupted: mocks.interrupted, @@ -34,6 +36,9 @@ afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.clearAllMocks(); + mocks.dependencies.mockResolvedValue(true); + mocks.ready.mockResolvedValue(false); + mocks.compatible.mockResolvedValue(false); mocks.interrupted.mockResolvedValue(false); mocks.promoteCaches.mockResolvedValue(undefined); }); @@ -364,3 +369,26 @@ it('reserves setup before asynchronous checks and cancels stale preflight', asyn expect(mocks.install).not.toHaveBeenCalled(); expect(supervisor.status.stage).toBe('idle'); }); + +it.each(['ready', 'compatible'] as const)( + 'offers setup instead of spawning a %s runtime with missing dependencies', + async (kind) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('no backend'); + }), + ); + vi.stubEnv('OMNIVOICE_BACKEND_CMD', ''); + vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', ''); + mocks[kind].mockResolvedValue(true); + mocks.dependencies.mockResolvedValue(false); + const supervisor = new BackendSupervisor(); + await supervisor.start(); + expect(supervisor.status.stage).toBe('setup_required'); + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(mocks.install).not.toHaveBeenCalled(); + expect(mocks.stage).not.toHaveBeenCalled(); + await supervisor.shutdown(); + }, +); diff --git a/electron/src/main/backend.ts b/electron/src/main/backend.ts index 74506f06..e6babe95 100644 --- a/electron/src/main/backend.ts +++ b/electron/src/main/backend.ts @@ -2,6 +2,7 @@ import { installRuntime, promoteLegacyRuntimeCaches, runtimeCompatible, + runtimeDependenciesReady, runtimeInstallInterrupted, runtimeReady, runtimePython, @@ -482,11 +483,8 @@ export class BackendSupervisor extends EventEmitter<{ } if (app.isPackaged && !parseBackendCmdOverride(process.env.OMNIVOICE_BACKEND_CMD)) { - const project = await this.resolveRuntimeProject(); - if ( - !(await runtimeReady(backendRoot(), project)) && - !(await runtimeCompatible(backendRoot(), project)) - ) { + const { project, ready } = await this.resolveRuntimeProject(); + if (!ready) { if (gen === this.generation) { this.runtimeInterrupted = await runtimeInstallInterrupted(project); this.setStage('setup_required'); @@ -537,8 +535,9 @@ export class BackendSupervisor extends EventEmitter<{ this.setStage('installing', { message: undefined }); try { const reusable = - (await runtimeReady(backendRoot(), project)) || - (await runtimeCompatible(backendRoot(), project)); + ((await runtimeReady(backendRoot(), project)) || + (await runtimeCompatible(backendRoot(), project))) && + (await runtimeDependenciesReady(project)); if (gen !== this.generation || controller.signal.aborted) return; if (reusable) { await this.start(); @@ -797,7 +796,7 @@ export class BackendSupervisor extends EventEmitter<{ this.emitStatus(); } - private async resolveRuntimeProject(): Promise { + private async resolveRuntimeProject(): Promise<{ project: string; ready: boolean }> { const bundle = backendRoot(); const own = join(defaultRuntimeRoot(), 'project'); const configuredRoot = storedRuntimeRoot(); @@ -809,15 +808,18 @@ export class BackendSupervisor extends EventEmitter<{ ...legacyTauriRuntimeProjects(), ].filter((candidate): candidate is string => Boolean(candidate)); for (const project of new Set(candidates.map((candidate) => resolve(candidate)))) { - if ((await runtimeReady(bundle, project)) || (await runtimeCompatible(bundle, project))) { + if ( + ((await runtimeReady(bundle, project)) || (await runtimeCompatible(bundle, project))) && + (await runtimeDependenciesReady(project)) + ) { this.runtimeProject = project; if (project !== own && project !== configured) this.pushLog('out', `Reusing compatible Tauri runtime: ${project}`); - return project; + return { project, ready: true }; } } this.runtimeProject = configured ?? own; - return this.runtimeProject; + return { project: this.runtimeProject, ready: false }; } private emitStatus(): void { @@ -889,7 +891,10 @@ export class BackendSupervisor extends EventEmitter<{ // Python passes this child-side descriptor to every nested operation. // Reading the parent side keeps the ownership channel live and lets // Node observe EOF only after the complete backend subtree releases it. - const drain = child.stdio?.[processOptions.drainFd] as NodeJS.ReadableStream | null | undefined; + const drain = child.stdio?.[processOptions.drainFd] as + | NodeJS.ReadableStream + | null + | undefined; drain?.on('error', (error: unknown) => { if (!isExpectedPipeClose(error)) { this.pushLog('err', `Backend drain stream failed: ${errorMessage(error)}`); diff --git a/electron/src/main/runtime-dependencies.test.ts b/electron/src/main/runtime-dependencies.test.ts new file mode 100644 index 00000000..7783fc8a --- /dev/null +++ b/electron/src/main/runtime-dependencies.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment node +import { afterEach, expect, it, vi } from 'vitest'; +import { execFile } from 'node:child_process'; +import { runtimeDependenciesReady, runtimePython } from './runtime-project'; +vi.mock('node:child_process', () => ({ execFile: vi.fn() })); +afterEach(() => vi.clearAllMocks()); +it.each([null, new Error('No module named uvicorn'), new Error('ETIMEDOUT'), new Error('ENOENT')])( + 'validates imports using the selected interpreter and fails closed (%s)', + async (error) => { + vi.mocked(execFile).mockImplementation((( + _command: unknown, + _args: unknown, + _options: unknown, + callback: (error: Error | null) => void, + ) => callback(error)) as never); + const project = '/runtime with spaces'; + expect(await runtimeDependenciesReady(project)).toBe(error === null); + expect(execFile).toHaveBeenCalledWith( + runtimePython(project), + ['-c', 'import fastapi, uvicorn, omnivoice, faster_whisper'], + expect.objectContaining({ + cwd: project, + timeout: 30_000, + windowsHide: true, + env: expect.objectContaining({ + HF_HUB_OFFLINE: '1', + TRANSFORMERS_OFFLINE: '1', + PYTHONNOUSERSITE: '1', + }), + }), + expect.any(Function), + ); + }, +); diff --git a/electron/src/main/runtime-project.ts b/electron/src/main/runtime-project.ts index eceeaa08..6c21f565 100644 --- a/electron/src/main/runtime-project.ts +++ b/electron/src/main/runtime-project.ts @@ -1,3 +1,4 @@ +import { execFile } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { cp, @@ -247,6 +248,29 @@ export async function runtimeInstallInterrupted(project: string): Promise { + return new Promise((resolve) => { + execFile( + runtimePython(project), + ['-c', 'import fastapi, uvicorn, omnivoice, faster_whisper'], + { + cwd: project, + windowsHide: true, + timeout: 30_000, + maxBuffer: 256 * 1024, + env: { + ...process.env, + HF_HUB_OFFLINE: '1', + TRANSFORMERS_OFFLINE: '1', + PYTHONNOUSERSITE: '1', + }, + }, + (error) => resolve(!error), + ); + }); +} + export async function runtimeReady(bundle: string, project: string): Promise { if (await runtimeIncomplete(project)) return false; try { From af8810be5394296a1eac143324017ab05646ecad Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:13:18 +0530 Subject: [PATCH 04/11] fix(electron): offer retry when engine capability lookup fails --- .../src/features/tools/convert-voice.test.tsx | 20 ++++++++++++++++++- .../src/features/tools/convert-voice.tsx | 8 +++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/electron/src/renderer/src/features/tools/convert-voice.test.tsx b/electron/src/renderer/src/features/tools/convert-voice.test.tsx index 6658e8df..9587a4b8 100644 --- a/electron/src/renderer/src/features/tools/convert-voice.test.tsx +++ b/electron/src/renderer/src/features/tools/convert-voice.test.tsx @@ -2,9 +2,13 @@ import { clearConversion } from './conversion-state'; import { cleanup, fireEvent, render, screen, act } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { afterEach, expect, it, vi } from 'vitest'; -const mock = vi.hoisted(() => ({ convert: vi.fn(), ready: true, cloning: true as boolean | null })); +const mock = vi.hoisted(() => ({ queryError: false, retry: vi.fn(), convert: vi.fn(), ready: true, cloning: true as boolean | null })); vi.mock('@/hooks/use-engines', () => ({ useEngines: () => ({ + isError: mock.queryError, + error: new Error("Engine query failed"), + retry: mock.retry, + data: mock.queryError ? undefined : {}, activeTtsReady: mock.ready, activeTts: { supports_cloning: mock.cloning }, }), @@ -31,6 +35,7 @@ import { ConvertVoice } from './convert-voice'; afterEach(() => { cleanup(); clearConversion(); + mock.queryError = false; mock.ready = true; mock.cloning = true; vi.clearAllMocks(); @@ -124,3 +129,16 @@ it('rechecks engine capability when returning from settings without losing input expect(screen.getByRole('button', { name: 'convert.convert' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'source.wav' })).toBeInTheDocument(); }); + +it('offers retry instead of model guidance when engine lookup fails', () => { + mock.queryError = true; + mock.ready = false; + const { upload } = mount(); + upload(); + fireEvent.click(screen.getByRole('button', { name: 'Alpha' })); + expect(screen.getByRole('button', { name: 'convert.convert' })).toBeDisabled(); + expect(screen.queryByText('convert.cloning_required')).not.toBeInTheDocument(); + expect(screen.getByText('Engine query failed')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'common.retry' })); + expect(mock.retry).toHaveBeenCalledOnce(); +}); diff --git a/electron/src/renderer/src/features/tools/convert-voice.tsx b/electron/src/renderer/src/features/tools/convert-voice.tsx index fe2b5f6d..cf58ed6b 100644 --- a/electron/src/renderer/src/features/tools/convert-voice.tsx +++ b/electron/src/renderer/src/features/tools/convert-voice.tsx @@ -213,7 +213,13 @@ export function ConvertVoice() {

{t('convert.match_duration_hint')}

- {!canClone && !busy && ( + {engines.isError && !engines.data && !busy && ( +
+ {describeError(engines.error)} + +
+ )} + {!canClone && !(engines.isError && !engines.data) && !busy && (
Date: Thu, 17 Sep 2026 20:13:38 +0530 Subject: [PATCH 05/11] test(setup): validate file lists and isolate token fixtures --- tests/test_gated_install_token_2163.py | 3 ++- tests/test_models_catalog.py | 7 +++++-- tests/test_pyannote_install_2163.py | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_gated_install_token_2163.py b/tests/test_gated_install_token_2163.py index 8a062500..eb412c63 100644 --- a/tests/test_gated_install_token_2163.py +++ b/tests/test_gated_install_token_2163.py @@ -25,7 +25,6 @@ os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1") import pytest -from services.token_resolver import ResolvedToken @pytest.fixture @@ -88,6 +87,7 @@ def _drive_segmented(download, monkeypatch, tmp_path, resolved): def test_segmented_install_sends_the_bearer_string_to_every_consumer( download, monkeypatch, tmp_path ): + from services.token_resolver import ResolvedToken seen = _drive_segmented( download, monkeypatch, @@ -122,6 +122,7 @@ def test_a_token_record_would_build_a_broken_authorization_header(): header; given the record it produces a malformed one that also inlines the raw secret. This is the failure #2163 reported as a 401. """ + from services.token_resolver import ResolvedToken from services.segmented_download import _auth_headers url = "https://huggingface.co/pyannote/speaker-diarization-3.1/resolve/main/config.yaml" diff --git a/tests/test_models_catalog.py b/tests/test_models_catalog.py index 19055115..67e3707d 100644 --- a/tests/test_models_catalog.py +++ b/tests/test_models_catalog.py @@ -55,7 +55,7 @@ def test_config_only_entries_declare_the_files_that_complete_them(): if not m.get("config_only"): continue required = m.get("config_required_files") - assert required, f"{m['repo_id']}: config_only needs config_required_files" + assert isinstance(required, list) and required, f"{m['repo_id']}: config_only needs a nonempty config_required_files list" assert all( isinstance(name, str) and name.strip() for name in required ), f"{m['repo_id']}: blank entry in config_required_files" @@ -68,7 +68,10 @@ def test_dependency_declarations_are_installable(): for dependency in m.get("dependencies") or (): rid = dependency.get("repo_id") assert rid and _REPO_RE.match(rid), f"malformed dependency repo_id: {rid!r}" - assert dependency.get("required_files"), ( + required = dependency.get("required_files") + assert isinstance(required, list) and required and all( + isinstance(name, str) and name.strip() for name in required + ), ( f"{m['repo_id']} → {rid}: dependency needs required_files" ) diff --git a/tests/test_pyannote_install_2163.py b/tests/test_pyannote_install_2163.py index d415c656..b2f1da57 100644 --- a/tests/test_pyannote_install_2163.py +++ b/tests/test_pyannote_install_2163.py @@ -25,7 +25,6 @@ os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1") import pytest -from services.token_resolver import ResolvedToken PIPELINE = "pyannote/speaker-diarization-3.1" @@ -45,6 +44,7 @@ def _install_pyannote(download, monkeypatch, tmp_path): Returns (snapshot_download kwargs per call, emitted SSE events). """ + from services.token_resolver import ResolvedToken import huggingface_hub from services import hf_revisions, performance_profiles, token_resolver from utils import hf_progress From 86c37c8d0406a4875fc8176d4b3b881ea4e141c4 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:14:09 +0530 Subject: [PATCH 06/11] style: format engine retry guidance --- .../renderer/src/features/tools/convert-voice.test.tsx | 10 ++++++++-- .../src/renderer/src/features/tools/convert-voice.tsx | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/electron/src/renderer/src/features/tools/convert-voice.test.tsx b/electron/src/renderer/src/features/tools/convert-voice.test.tsx index 9587a4b8..67034503 100644 --- a/electron/src/renderer/src/features/tools/convert-voice.test.tsx +++ b/electron/src/renderer/src/features/tools/convert-voice.test.tsx @@ -2,11 +2,17 @@ import { clearConversion } from './conversion-state'; import { cleanup, fireEvent, render, screen, act } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { afterEach, expect, it, vi } from 'vitest'; -const mock = vi.hoisted(() => ({ queryError: false, retry: vi.fn(), convert: vi.fn(), ready: true, cloning: true as boolean | null })); +const mock = vi.hoisted(() => ({ + queryError: false, + retry: vi.fn(), + convert: vi.fn(), + ready: true, + cloning: true as boolean | null, +})); vi.mock('@/hooks/use-engines', () => ({ useEngines: () => ({ isError: mock.queryError, - error: new Error("Engine query failed"), + error: new Error('Engine query failed'), retry: mock.retry, data: mock.queryError ? undefined : {}, activeTtsReady: mock.ready, diff --git a/electron/src/renderer/src/features/tools/convert-voice.tsx b/electron/src/renderer/src/features/tools/convert-voice.tsx index cf58ed6b..083ca2b6 100644 --- a/electron/src/renderer/src/features/tools/convert-voice.tsx +++ b/electron/src/renderer/src/features/tools/convert-voice.tsx @@ -214,9 +214,14 @@ export function ConvertVoice() {

{t('convert.match_duration_hint')}

{engines.isError && !engines.data && !busy && ( -
+
{describeError(engines.error)} - +
)} {!canClone && !(engines.isError && !engines.data) && !busy && ( From d6efb14849daf4aa180e126aebf15231b82df4ed Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:23:05 +0530 Subject: [PATCH 07/11] fix(electron): preserve selected runtime ownership during repair --- docs/electron-migration.md | 5 +++ electron/src/main/backend-setup.test.ts | 58 ++++++++++++++++++++++++- electron/src/main/backend.ts | 33 ++++++++++---- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/docs/electron-migration.md b/docs/electron-migration.md index 72d9f64d..8fae6c64 100644 --- a/docs/electron-migration.md +++ b/docs/electron-migration.md @@ -23,3 +23,8 @@ Electron checks required Python imports before reusing an existing runtime. An incomplete environment opens setup instead of repeatedly crashing; installation still requires your explicit action. Automatic selection skips broken legacy runtimes and uses the Electron runtime location, leaving Tauri data intact. + +An explicitly selected runtime is not silently replaced during startup. If that +location contains an incomplete environment Electron does not own, choosing +setup creates a separate runtime in Electron’s default location instead of +modifying or taking ownership of the existing environment. diff --git a/electron/src/main/backend-setup.test.ts b/electron/src/main/backend-setup.test.ts index 0e8a0887..f0736381 100644 --- a/electron/src/main/backend-setup.test.ts +++ b/electron/src/main/backend-setup.test.ts @@ -2,7 +2,9 @@ import { afterEach, expect, it, vi } from 'vitest'; import { EventEmitter } from 'node:events'; const mocks = vi.hoisted(() => ({ - dependencies: vi.fn(async () => true), + runtimeConfig: null as { root: string; owned: boolean } | null, + existingProject: false, + dependencies: vi.fn(async (_project?: string) => true), ready: vi.fn(async () => false), compatible: vi.fn(async () => false), interrupted: vi.fn(async () => false), @@ -14,6 +16,27 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('electron', () => ({ app: { isPackaged: true, getPath: () => '/private/voicestudio' } })); vi.mock('node:child_process', () => ({ spawn: mocks.spawn, spawnSync: vi.fn() })); +vi.mock('node:fs', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + readFileSync: (...args: Parameters) => + String(args[0]).endsWith('runtime-location.json') && mocks.runtimeConfig + ? JSON.stringify(mocks.runtimeConfig) + : original.readFileSync(...args), + writeFileSync: (...args: Parameters) => { + if (String(args[0]).endsWith('runtime-location.json')) { + mocks.runtimeConfig = JSON.parse(String(args[1])); + return; + } + return original.writeFileSync(...args); + }, + mkdirSync: (...args: Parameters) => + String(args[0]).includes('private') ? undefined : original.mkdirSync(...args), + existsSync: (path: Parameters[0]) => + mocks.existingProject && String(path).includes('selected') ? true : original.existsSync(path), + }; +}); vi.mock('node:fs/promises', () => ({ rm: mocks.rm })); vi.mock('./runtime-project', () => ({ runtimeDependenciesReady: mocks.dependencies, @@ -36,6 +59,8 @@ afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.clearAllMocks(); + mocks.runtimeConfig = null; + mocks.existingProject = false; mocks.dependencies.mockResolvedValue(true); mocks.ready.mockResolvedValue(false); mocks.compatible.mockResolvedValue(false); @@ -392,3 +417,34 @@ it.each(['ready', 'compatible'] as const)( await supervisor.shutdown(); }, ); + +it('keeps a selected broken environment selected until explicit setup, then preserves unowned files', async () => { + const { resolve, join } = await import('node:path'); + const selected = resolve('/selected/VoiceStudio'); + mocks.runtimeConfig = { root: selected, owned: false }; + mocks.existingProject = true; + mocks.ready.mockResolvedValue(true); + mocks.dependencies.mockImplementation(async (project?: string) => !project?.includes('selected')); + mocks.install.mockRejectedValue(new Error('offline')); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('no backend'); + }), + ); + vi.stubEnv('OMNIVOICE_BACKEND_CMD', ''); + vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', ''); + const supervisor = new BackendSupervisor(); + await supervisor.start(); + expect(supervisor.status.stage).toBe('setup_required'); + expect( + mocks.dependencies.mock.calls.every(([project]) => String(project).includes('selected')), + ).toBe(true); + expect(mocks.install).not.toHaveBeenCalled(); + await supervisor.setupRuntime(); + expect(mocks.install.mock.calls[0][1]).toBe(join('/private/voicestudio', 'runtime', 'project')); + expect(mocks.runtimeConfig?.root).not.toBe(selected); + expect(mocks.rm).not.toHaveBeenCalled(); + expect(mocks.stage).not.toHaveBeenCalled(); + await supervisor.shutdown(); +}); diff --git a/electron/src/main/backend.ts b/electron/src/main/backend.ts index e6babe95..b13e49ae 100644 --- a/electron/src/main/backend.ts +++ b/electron/src/main/backend.ts @@ -520,7 +520,7 @@ export class BackendSupervisor extends EventEmitter<{ this.stage !== 'setup_required' ) return; - const project = + let project = this.runtimeProject ?? join(storedRuntimeRoot() ?? defaultRuntimeRoot(), 'project'); this.runtimeProject = project; const controller = new AbortController(); @@ -543,8 +543,27 @@ export class BackendSupervisor extends EventEmitter<{ await this.start(); return; } - const runtimeRoot = dirname(project); + let runtimeRoot = dirname(project); const configured = storedRuntimeLocation(); + if ( + configured && + !configured.owned && + samePath(configured.root, runtimeRoot) && + !samePath(runtimeRoot, defaultRuntimeRoot()) && + existsSync(project) + ) { + // An explicit setup action may create a new runtime, but must never + // take ownership of (or repair in place) another installation's files. + runtimeRoot = defaultRuntimeRoot(); + project = join(runtimeRoot, 'project'); + this.runtimeProject = project; + writeRuntimeLocation(runtimeRoot, true); + this.pushLog( + 'out', + 'Creating a separate Electron runtime; existing environment preserved.', + ); + this.emitStatus(); + } if ( configured && samePath(configured.root, runtimeRoot) && @@ -801,12 +820,10 @@ export class BackendSupervisor extends EventEmitter<{ const own = join(defaultRuntimeRoot(), 'project'); const configuredRoot = storedRuntimeRoot(); const configured = configuredRoot ? join(configuredRoot, 'project') : null; - const candidates = [ - this.runtimeProject, - configured, - own, - ...legacyTauriRuntimeProjects(), - ].filter((candidate): candidate is string => Boolean(candidate)); + // Explicit selection is authoritative, including when it needs setup. + const candidates = ( + configured ? [configured] : [this.runtimeProject, own, ...legacyTauriRuntimeProjects()] + ).filter((candidate): candidate is string => Boolean(candidate)); for (const project of new Set(candidates.map((candidate) => resolve(candidate)))) { if ( ((await runtimeReady(bundle, project)) || (await runtimeCompatible(bundle, project))) && From d217553e2ea7e7573dc60126b02a459189cd5dc9 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:30:38 +0530 Subject: [PATCH 08/11] fix(electron): preserve unowned roots without project folders --- electron/src/main/backend-setup.test.ts | 65 +++++++++++++------------ electron/src/main/backend.ts | 3 +- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/electron/src/main/backend-setup.test.ts b/electron/src/main/backend-setup.test.ts index f0736381..1ba5cfa5 100644 --- a/electron/src/main/backend-setup.test.ts +++ b/electron/src/main/backend-setup.test.ts @@ -418,33 +418,38 @@ it.each(['ready', 'compatible'] as const)( }, ); -it('keeps a selected broken environment selected until explicit setup, then preserves unowned files', async () => { - const { resolve, join } = await import('node:path'); - const selected = resolve('/selected/VoiceStudio'); - mocks.runtimeConfig = { root: selected, owned: false }; - mocks.existingProject = true; - mocks.ready.mockResolvedValue(true); - mocks.dependencies.mockImplementation(async (project?: string) => !project?.includes('selected')); - mocks.install.mockRejectedValue(new Error('offline')); - vi.stubGlobal( - 'fetch', - vi.fn(async () => { - throw new Error('no backend'); - }), - ); - vi.stubEnv('OMNIVOICE_BACKEND_CMD', ''); - vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', ''); - const supervisor = new BackendSupervisor(); - await supervisor.start(); - expect(supervisor.status.stage).toBe('setup_required'); - expect( - mocks.dependencies.mock.calls.every(([project]) => String(project).includes('selected')), - ).toBe(true); - expect(mocks.install).not.toHaveBeenCalled(); - await supervisor.setupRuntime(); - expect(mocks.install.mock.calls[0][1]).toBe(join('/private/voicestudio', 'runtime', 'project')); - expect(mocks.runtimeConfig?.root).not.toBe(selected); - expect(mocks.rm).not.toHaveBeenCalled(); - expect(mocks.stage).not.toHaveBeenCalled(); - await supervisor.shutdown(); -}); +it.each([true, false])( + 'preserves a selected unowned runtime (project exists: %s)', + async (projectExists) => { + const { resolve, join } = await import('node:path'); + const selected = resolve('/selected/VoiceStudio'); + mocks.runtimeConfig = { root: selected, owned: false }; + mocks.existingProject = projectExists; + mocks.ready.mockResolvedValue(true); + mocks.dependencies.mockImplementation( + async (project?: string) => !project?.includes('selected'), + ); + mocks.install.mockRejectedValue(new Error('offline')); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('no backend'); + }), + ); + vi.stubEnv('OMNIVOICE_BACKEND_CMD', ''); + vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', ''); + const supervisor = new BackendSupervisor(); + await supervisor.start(); + expect(supervisor.status.stage).toBe('setup_required'); + expect( + mocks.dependencies.mock.calls.every(([project]) => String(project).includes('selected')), + ).toBe(true); + expect(mocks.install).not.toHaveBeenCalled(); + await supervisor.setupRuntime(); + expect(mocks.install.mock.calls[0][1]).toBe(join('/private/voicestudio', 'runtime', 'project')); + expect(mocks.runtimeConfig?.root).not.toBe(selected); + expect(mocks.rm).not.toHaveBeenCalled(); + expect(mocks.stage).not.toHaveBeenCalled(); + await supervisor.shutdown(); + }, +); diff --git a/electron/src/main/backend.ts b/electron/src/main/backend.ts index b13e49ae..147916f2 100644 --- a/electron/src/main/backend.ts +++ b/electron/src/main/backend.ts @@ -549,8 +549,7 @@ export class BackendSupervisor extends EventEmitter<{ configured && !configured.owned && samePath(configured.root, runtimeRoot) && - !samePath(runtimeRoot, defaultRuntimeRoot()) && - existsSync(project) + !samePath(runtimeRoot, defaultRuntimeRoot()) ) { // An explicit setup action may create a new runtime, but must never // take ownership of (or repair in place) another installation's files. From 49f9fe3b06eeabd719652b01e52ae497b538b84d Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:31:30 +0530 Subject: [PATCH 09/11] docs: explain recovery from gated installer token failures --- docs/install/troubleshooting.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/install/troubleshooting.md b/docs/install/troubleshooting.md index 7d0a8d69..f31095bb 100644 --- a/docs/install/troubleshooting.md +++ b/docs/install/troubleshooting.md @@ -188,7 +188,13 @@ before the token works for downloads. 3. Retry the job. The token state in **Settings → API Keys** should now show the "App" row with a green check next to your username. -**Linked issue:** [#35](https://github.com/debpalash/VoiceStudio/issues/35) +If the token and license are already valid but an older build still reports +401 during installation, update VoiceStudio and retry. Settings tokens now +reach both the fast download path and engine-weight installers; no token +rotation is needed for that fixed client bug. + +**Linked issues:** [#35](https://github.com/debpalash/VoiceStudio/issues/35), +[#2163](https://github.com/debpalash/VoiceStudio/issues/2163) ### PocketTTS gated weights From c52441188e7c9833a6bacf4afd94def24636b99f Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:36:10 +0530 Subject: [PATCH 10/11] fix(electron): retain fresh custom runtime destinations --- docs/electron-migration.md | 4 ++++ electron/src/main/backend-setup.test.ts | 30 ++++++++++++++++++++++++- electron/src/main/backend.ts | 3 ++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/electron-migration.md b/docs/electron-migration.md index 8fae6c64..7e516476 100644 --- a/docs/electron-migration.md +++ b/docs/electron-migration.md @@ -28,3 +28,7 @@ An explicitly selected runtime is not silently replaced during startup. If that location contains an incomplete environment Electron does not own, choosing setup creates a separate runtime in Electron’s default location instead of modifying or taking ownership of the existing environment. + +A new custom runtime destination remains selectable. Setup creates and owns it +only when that directory does not already exist; existing unowned roots stay +untouched even if their `project` subdirectory is missing. diff --git a/electron/src/main/backend-setup.test.ts b/electron/src/main/backend-setup.test.ts index 1ba5cfa5..98e5ab47 100644 --- a/electron/src/main/backend-setup.test.ts +++ b/electron/src/main/backend-setup.test.ts @@ -4,6 +4,7 @@ import { EventEmitter } from 'node:events'; const mocks = vi.hoisted(() => ({ runtimeConfig: null as { root: string; owned: boolean } | null, existingProject: false, + existingRoot: false, dependencies: vi.fn(async (_project?: string) => true), ready: vi.fn(async () => false), compatible: vi.fn(async () => false), @@ -34,7 +35,11 @@ vi.mock('node:fs', async (importOriginal) => { mkdirSync: (...args: Parameters) => String(args[0]).includes('private') ? undefined : original.mkdirSync(...args), existsSync: (path: Parameters[0]) => - mocks.existingProject && String(path).includes('selected') ? true : original.existsSync(path), + String(path).includes('selected') + ? String(path).endsWith('project') + ? mocks.existingProject + : mocks.existingRoot + : original.existsSync(path), }; }); vi.mock('node:fs/promises', () => ({ rm: mocks.rm })); @@ -61,6 +66,7 @@ afterEach(() => { vi.clearAllMocks(); mocks.runtimeConfig = null; mocks.existingProject = false; + mocks.existingRoot = false; mocks.dependencies.mockResolvedValue(true); mocks.ready.mockResolvedValue(false); mocks.compatible.mockResolvedValue(false); @@ -425,6 +431,7 @@ it.each([true, false])( const selected = resolve('/selected/VoiceStudio'); mocks.runtimeConfig = { root: selected, owned: false }; mocks.existingProject = projectExists; + mocks.existingRoot = true; mocks.ready.mockResolvedValue(true); mocks.dependencies.mockImplementation( async (project?: string) => !project?.includes('selected'), @@ -453,3 +460,24 @@ it.each([true, false])( await supervisor.shutdown(); }, ); + +it('installs into a newly selected custom destination that does not yet exist', async () => { + const { resolve, join } = await import('node:path'); + const selected = resolve('/selected/VoiceStudio'); + mocks.runtimeConfig = { root: selected, owned: false }; + mocks.install.mockRejectedValue(new Error('offline')); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('no backend'); + }), + ); + vi.stubEnv('OMNIVOICE_BACKEND_CMD', ''); + vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', ''); + const supervisor = new BackendSupervisor(); + await supervisor.start(); + await supervisor.setupRuntime(); + expect(mocks.install.mock.calls[0][1]).toBe(join(selected, 'project')); + expect(mocks.runtimeConfig).toEqual({ root: selected, owned: true }); + await supervisor.shutdown(); +}); diff --git a/electron/src/main/backend.ts b/electron/src/main/backend.ts index 147916f2..46406e58 100644 --- a/electron/src/main/backend.ts +++ b/electron/src/main/backend.ts @@ -549,7 +549,8 @@ export class BackendSupervisor extends EventEmitter<{ configured && !configured.owned && samePath(configured.root, runtimeRoot) && - !samePath(runtimeRoot, defaultRuntimeRoot()) + !samePath(runtimeRoot, defaultRuntimeRoot()) && + existsSync(runtimeRoot) ) { // An explicit setup action may create a new runtime, but must never // take ownership of (or repair in place) another installation's files. From 8a7ab51784014f4b4102101ba6d60559323514fa Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:06:12 +0530 Subject: [PATCH 11/11] fix(electron): isolate runtime probes and reuse healthy fallback --- docs/electron-migration.md | 4 +++ electron/src/main/backend-setup.test.ts | 32 +++++++++++++++++-- electron/src/main/backend.ts | 9 ++++++ .../src/main/runtime-dependencies.test.ts | 31 +++++++++++++++++- electron/src/main/runtime-project.ts | 13 ++++---- 5 files changed, 79 insertions(+), 10 deletions(-) diff --git a/docs/electron-migration.md b/docs/electron-migration.md index 7e516476..9d2c50b6 100644 --- a/docs/electron-migration.md +++ b/docs/electron-migration.md @@ -32,3 +32,7 @@ modifying or taking ownership of the existing environment. A new custom runtime destination remains selectable. Setup creates and owns it only when that directory does not already exist; existing unowned roots stay untouched even if their `project` subdirectory is missing. + +Dependency checks ignore inherited `PYTHONPATH` and `PYTHONHOME`, matching backend +startup. If repair switches away from an unowned environment and a healthy +Electron runtime already exists, it is reused without reinstalling dependencies. diff --git a/electron/src/main/backend-setup.test.ts b/electron/src/main/backend-setup.test.ts index 98e5ab47..0433867e 100644 --- a/electron/src/main/backend-setup.test.ts +++ b/electron/src/main/backend-setup.test.ts @@ -433,9 +433,7 @@ it.each([true, false])( mocks.existingProject = projectExists; mocks.existingRoot = true; mocks.ready.mockResolvedValue(true); - mocks.dependencies.mockImplementation( - async (project?: string) => !project?.includes('selected'), - ); + mocks.dependencies.mockResolvedValue(false); mocks.install.mockRejectedValue(new Error('offline')); vi.stubGlobal( 'fetch', @@ -481,3 +479,31 @@ it('installs into a newly selected custom destination that does not yet exist', expect(mocks.runtimeConfig).toEqual({ root: selected, owned: true }); await supervisor.shutdown(); }); + +it('reuses a healthy default runtime after explicit setup leaves an unowned environment', async () => { + const { resolve, join } = await import('node:path'); + mocks.runtimeConfig = { root: resolve('/selected/VoiceStudio'), owned: false }; + mocks.existingRoot = true; + mocks.ready.mockResolvedValue(true); + mocks.dependencies.mockImplementation(async (project?: string) => !project?.includes('selected')); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('no backend'); + }), + ); + vi.stubEnv('OMNIVOICE_BACKEND_CMD', ''); + vi.stubEnv('VOICESTUDIO_SKIP_BACKEND', ''); + const supervisor = new BackendSupervisor(); + await supervisor.start(); + expect(supervisor.status.stage).toBe('setup_required'); + const restart = vi.spyOn(supervisor, 'start').mockResolvedValue(); + await supervisor.setupRuntime(); + expect(mocks.install).not.toHaveBeenCalled(); + expect(mocks.dependencies).toHaveBeenCalledWith( + join('/private/voicestudio', 'runtime', 'project'), + ); + expect(restart).toHaveBeenCalledOnce(); + restart.mockRestore(); + await supervisor.shutdown(); +}); diff --git a/electron/src/main/backend.ts b/electron/src/main/backend.ts index 46406e58..e29c3289 100644 --- a/electron/src/main/backend.ts +++ b/electron/src/main/backend.ts @@ -563,6 +563,15 @@ export class BackendSupervisor extends EventEmitter<{ 'Creating a separate Electron runtime; existing environment preserved.', ); this.emitStatus(); + const fallbackReusable = + ((await runtimeReady(backendRoot(), project)) || + (await runtimeCompatible(backendRoot(), project))) && + (await runtimeDependenciesReady(project)); + if (gen !== this.generation || controller.signal.aborted) return; + if (fallbackReusable) { + await this.start(); + return; + } } if ( configured && diff --git a/electron/src/main/runtime-dependencies.test.ts b/electron/src/main/runtime-dependencies.test.ts index 7783fc8a..31a01770 100644 --- a/electron/src/main/runtime-dependencies.test.ts +++ b/electron/src/main/runtime-dependencies.test.ts @@ -3,7 +3,10 @@ import { afterEach, expect, it, vi } from 'vitest'; import { execFile } from 'node:child_process'; import { runtimeDependenciesReady, runtimePython } from './runtime-project'; vi.mock('node:child_process', () => ({ execFile: vi.fn() })); -afterEach(() => vi.clearAllMocks()); +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); it.each([null, new Error('No module named uvicorn'), new Error('ETIMEDOUT'), new Error('ENOENT')])( 'validates imports using the selected interpreter and fails closed (%s)', async (error) => { @@ -32,3 +35,29 @@ it.each([null, new Error('No module named uvicorn'), new Error('ETIMEDOUT'), new ); }, ); + +it.each(['PYTHONPATH', 'PYTHONHOME'] as const)( + 'isolates imports from inherited %s', + async (variable) => { + vi.stubEnv(variable, '/unrelated-python'); + vi.mocked(execFile).mockImplementation((( + _command: unknown, + _args: unknown, + options: { env: NodeJS.ProcessEnv }, + callback: (error: Error | null) => void, + ) => { + const contaminated = Boolean(options.env[variable]); + callback( + variable === 'PYTHONPATH' + ? contaminated + ? null + : new Error('No module named uvicorn') + : contaminated + ? new Error('invalid Python home') + : null, + ); + }) as never); + expect(await runtimeDependenciesReady('/selected-runtime')).toBe(variable === 'PYTHONHOME'); + expect(process.env[variable]).toBe('/unrelated-python'); + }, +); diff --git a/electron/src/main/runtime-project.ts b/electron/src/main/runtime-project.ts index 6c21f565..762241db 100644 --- a/electron/src/main/runtime-project.ts +++ b/electron/src/main/runtime-project.ts @@ -250,6 +250,12 @@ export async function runtimeInstallInterrupted(project: string): Promise { + const env: NodeJS.ProcessEnv = { ...process.env }; + delete env.PYTHONHOME; + delete env.PYTHONPATH; + env.HF_HUB_OFFLINE = '1'; + env.TRANSFORMERS_OFFLINE = '1'; + env.PYTHONNOUSERSITE = '1'; return new Promise((resolve) => { execFile( runtimePython(project), @@ -259,12 +265,7 @@ export async function runtimeDependenciesReady(project: string): Promise resolve(!error), );