diff --git a/backend/core/failure.py b/backend/core/failure.py index 6284562c..9d68eae1 100644 --- a/backend/core/failure.py +++ b/backend/core/failure.py @@ -42,6 +42,7 @@ _HINTS: dict[str, str] = { "TRANSFORMERS_IMPORT": "Your transformers install is incomplete. Reinstall it (`uv pip install --reinstall transformers`) or switch ASR to faster-whisper (Settings → Models).", "OS_INVALID_ARGUMENT": "The OS rejected a file operation (Errno 22 / invalid argument) — in the transcribe path this is the temporary WAV write before ASR. It's almost always the temp directory: missing, read-only, on a full or removed drive, or blocked by antivirus. Check that your system TEMP/TMP folder exists and is writable and the drive has free space (add an OmniVoice antivirus exclusion if you use one), then retry.", "SOCKS_PROXY_SUPPORT_MISSING": "A SOCKS proxy is configured in your environment (ALL_PROXY/HTTPS_PROXY=socks5://…) and the backend's HTTP client is missing SOCKS support. Newer OmniVoice builds ship SOCKS support (the socksio package) — update the app. If you still see this, unset ALL_PROXY/HTTPS_PROXY for OmniVoice, or run `uv pip install 'httpx[socks]'` in the backend venv, then restart.", + "SSL_HANDSHAKE_FAILURE": "A corporate or antivirus proxy is intercepting HTTPS traffic and re-signing certificates with its own CA — your OS trusts that CA, but Python's bundled certifi CA list doesn't, so the TLS handshake fails even though the connection reached the server. Newer OmniVoice builds trust the OS certificate store at startup (the truststore package), which should already fix this — update the app and retry. If you still see this, add an HTTPS-scanning exclusion for OmniVoice/Python in your antivirus, or ask IT for the proxy's CA bundle and set SSL_CERT_FILE to it, then restart.", "UNSUPPORTED_VIDEO_URL": "This link isn't a directly downloadable video. Paste a direct video page (e.g. a youtube.com/watch?v=… or douyin.com/video/ link), not a share/profile/feed link — or download the file and drop it in directly.", "VIDEO_DOWNLOAD_NETWORK": "The connection to the video server dropped mid-download (often a transient CDN/network blip or a regional rate-limit). Just retry — OmniVoice already cleaned up the partial download. If it keeps failing, check your network/VPN.", "BROKEN_VENV": "The Python backend environment was moved or damaged. OmniVoice rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.", @@ -177,6 +178,7 @@ def append_hf_mirror_hint(text: str) -> str: # hint on a model-load timeout that leaks through the 500 handler. _CONTEXT_FREE_HINT_CLASSES = frozenset({ "SOCKS_PROXY_SUPPORT_MISSING", + "SSL_HANDSHAKE_FAILURE", }) @@ -257,6 +259,19 @@ def classify(reason: str) -> str: # a message that also carries HF wording still names this class. if "socks proxy" in low or "socksio" in low: return "SOCKS_PROXY_SUPPORT_MISSING" + # #976: a TLS handshake failing AFTER the TCP connection succeeds — the + # signature of a corporate/antivirus proxy that TLS-inspects traffic and + # re-signs certificates with a CA the OS trusts but Python's bundled + # certifi list doesn't (a different failure mode from #984's TCP-level + # "can't reach the host at all"). Requires "ssl" plus a handshake/cert- + # verify marker so a generic connection error isn't mislabelled. + if "ssl" in low and ( + "handshake" in low + or "certificate verify failed" in low + or "sslv3_alert" in low + or "sslcertverificationerror" in low + ): + return "SSL_HANDSHAKE_FAILURE" if ("huggingface" in low or "hf_token" in low or "401" in low or "unauthorized" in low) and ( "token" in low or "auth" in low or "401" in low or "unauthorized" in low ): diff --git a/backend/main.py b/backend/main.py index 9b728f62..995358f1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -139,6 +139,24 @@ os.environ.setdefault("HF_HUB_DISABLE_XET", "1") os.environ.setdefault("HF_HUB_ETAG_TIMEOUT", "15") os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "30") +# ── OS trust store for TLS (#976) ─────────────────────────────────────────── +# Users behind a corporate/antivirus proxy that TLS-inspects HTTPS traffic get +# a raw "[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] ssl/tls alert handshake failure" +# on every model install — the TCP connection succeeds (a different failure +# mode from #984's TCP-level blocked-host case), but the proxy re-signs the +# certificate with its own root CA, which the OS trusts (Windows CryptoAPI/ +# SChannel) and Python's bundled `certifi` CA list does not. `inject_into_ssl` +# patches `ssl.SSLContext` process-wide to verify against the OS trust store +# instead, which is the actual fix (not just a nicer error message). Must run +# here — at MODULE level, before huggingface_hub/requests/httpx do any network +# I/O — not inside lifespan(), which runs too late. Not platform-gated: it's a +# correctness improvement everywhere. Best-effort: never block startup. +try: + import truststore + + truststore.inject_into_ssl() +except Exception: + pass # Prevent torchaudio from lazy-importing torchcodec (broken on some installs). # Proper fix = exclude torchcodec in pyproject.toml; this is a belt-and-braces guard. diff --git a/pyproject.toml b/pyproject.toml index b63aacdb..734c379e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,6 +154,15 @@ dependencies = [ # httpx imports it lazily inside try/except, so PyInstaller's tracer # misses it and frozen installers would stay broken without the entry. "socksio>=1.0", + # OS trust store for TLS (#976). Users behind a corporate/antivirus proxy + # that TLS-inspects traffic get a raw "[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE]" + # on every model install — the TCP connection succeeds, but the proxy's + # re-signed certificate is trusted by the OS (Windows CryptoAPI/SChannel) + # and not by Python's bundled `certifi` CA list. `truststore` patches + # `ssl.SSLContext` to verify against the OS trust store instead. Pure- + # Python, MIT, PyPA-maintained, zero transitive deps — same class of fix + # as socksio above, identical on macOS/Windows/Linux. + "truststore>=0.9", ] [project.optional-dependencies] diff --git a/tests/test_failure_classify.py b/tests/test_failure_classify.py index a79b1e07..1d888594 100644 --- a/tests/test_failure_classify.py +++ b/tests/test_failure_classify.py @@ -143,6 +143,37 @@ def test_classify_socks_proxy_support_missing(): assert failure.classify("ProxyError: connection refused by 10.0.0.1:8080") == "" +def test_classify_ssl_handshake_failure(): + # #976: the exact error a Windows user behind a corporate/antivirus + # TLS-inspecting proxy sees on every model install — the TCP connection + # succeeds, but the handshake fails because the OS trusts the proxy's + # re-signed CA and Python's bundled certifi list doesn't. A different + # failure mode from #984's TCP-level "host unreachable" fix. + reason = ( + "Install failed: Got: ConnectError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] " + "ssl/tls alert handshake failure (_ssl.c:1016)" + ) + assert failure.classify(reason) == "SSL_HANDSHAKE_FAILURE" + evt = failure.build_failure(reason, stage="install", include_diagnostic=False) + assert evt["docs_topic"] == "SSL_HANDSHAKE_FAILURE" + assert evt["hint"], "the SSL-handshake class must carry an actionable hint" + # A CERTIFICATE_VERIFY_FAILED-style message (the other common corporate-MITM + # shape) must classify the same way. + cert_reason = ( + "requests.exceptions.SSLError: HTTPSConnectionPool(host='huggingface.co', " + "port=443): Max retries exceeded with url: / (Caused by SSLError(" + "SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate " + "verify failed: unable to get local issuer certificate')))" + ) + assert failure.classify(cert_reason) == "SSL_HANDSHAKE_FAILURE" + # append_hint is the raw-string surface (setup/download.py's install SSE) — + # the detail keeps the real error AND gains the hint. + out = failure.append_hint(reason) + assert out.startswith(reason) and "truststore" in out + # A plain, unrelated connection error must NOT be mislabelled as SSL. + assert failure.classify("ConnectionError: connection refused") == "" + + def test_classify_generic_still_empty(): # A genuinely unknown reason must still classify to "" (no false hint). assert failure.classify("some totally unrelated failure") == "" diff --git a/uv.lock b/uv.lock index dea55e15..f27f8646 100644 --- a/uv.lock +++ b/uv.lock @@ -3247,6 +3247,7 @@ dependencies = [ { name = "torchaudio", version = "2.8.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, { name = "torchaudio", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "transformers" }, + { name = "truststore" }, { name = "uvicorn" }, { name = "webdataset" }, { name = "websockets" }, @@ -3328,6 +3329,7 @@ requires-dist = [ { name = "torchaudio", marker = "sys_platform != 'linux' and sys_platform != 'win32'", specifier = ">=2.4" }, { name = "torchaudio", marker = "sys_platform == 'linux' or sys_platform == 'win32'", specifier = ">=2.4", index = "https://download.pytorch.org/whl/cu128" }, { name = "transformers", specifier = ">=5.3.0" }, + { name = "truststore", specifier = ">=0.9" }, { name = "unidecode", marker = "extra == 'eval'" }, { name = "uvicorn" }, { name = "webdataset" }, @@ -6234,6 +6236,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/63/8cb444ad5cdb25d999b7d647abac25af0ee37d292afc009940c05b82dda0/triton-3.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7936b18a3499ed62059414d7df563e6c163c5e16c3773678a3ee3d417865035d", size = 155659780, upload-time = "2025-07-30T19:58:51.171Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.24.1"