From 78d92ddd8313d5e4f50874d9cd17c0c5c89a3f6c Mon Sep 17 00:00:00 2001 From: not-knope <198847158+not-knope@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:46:29 +0200 Subject: [PATCH] fix(packaging): bundle certifi CA bundle in PyInstaller binary Standalone macOS builds failed LLM preflight with a generic Connection error because cacert.pem was missing from the frozen bundle; also surface STRIX_DEBUG logs and exception cause chains before scan logging starts. Fixes #1008 --- hooks/rthooks/pyi_rth_certifi.py | 20 ++++++++ pyproject.toml | 2 + strix.spec | 5 +- strix/interface/main.py | 43 ++++++++++++++--- strix/telemetry/logging.py | 60 +++++++++++++++++++++--- tests/test_packaging.py | 14 ++++++ tests/test_preflight_logging.py | 80 ++++++++++++++++++++++++++++++++ 7 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 hooks/rthooks/pyi_rth_certifi.py create mode 100644 tests/test_preflight_logging.py diff --git a/hooks/rthooks/pyi_rth_certifi.py b/hooks/rthooks/pyi_rth_certifi.py new file mode 100644 index 00000000..a47e876e --- /dev/null +++ b/hooks/rthooks/pyi_rth_certifi.py @@ -0,0 +1,20 @@ +"""PyInstaller runtime hook: point SSL env vars at the bundled certifi CA file. + +Without ``collect_data_files('certifi')`` and this hook, a frozen binary can +resolve ``certifi.where()`` to a missing path and fail TLS verification with a +generic ``Connection error`` from httpx/litellm. +""" + +from __future__ import annotations + +import sys + + +if getattr(sys, "frozen", False): + import os + + import certifi + + ca_bundle = certifi.where() + os.environ.setdefault("SSL_CERT_FILE", ca_bundle) + os.environ.setdefault("REQUESTS_CA_BUNDLE", ca_bundle) diff --git a/pyproject.toml b/pyproject.toml index 1c88a1a2..31a84599 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -232,6 +232,8 @@ ignore = [ "tests/test_codex_auth.py" = ["S105", "S106", "SLF001"] # Hatchling loads the build hook by path, not as an importable package. "scripts/tui_sidecar_hook.py" = ["INP001"] +# PyInstaller runtime hooks are loaded by path, not as an importable package. +"hooks/rthooks/pyi_rth_certifi.py" = ["INP001"] # Stdlib HTTP handler overrides (do_GET/do_POST). "strix/interface/auth_cli.py" = ["N802"] "tests/test_codex_streaming.py" = ["N802"] diff --git a/strix.spec b/strix.spec index 827e5e2c..249f9cca 100644 --- a/strix.spec +++ b/strix.spec @@ -40,6 +40,9 @@ datas += collect_data_files('tiktoken') datas += collect_data_files('tiktoken_ext') datas += collect_data_files('litellm') +# Frozen binaries need certifi's CA bundle on disk; without it TLS to LLM +# providers fails with a generic httpx/litellm "Connection error". +datas += collect_data_files('certifi') datas += collect_data_files('agents', includes=['**/*.md', '**/*.jinja', '**/*.json']) @@ -251,7 +254,7 @@ a = Analysis( hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, - runtime_hooks=[], + runtime_hooks=[str(project_root / 'hooks' / 'rthooks' / 'pyi_rth_certifi.py')], excludes=excludes, noarchive=False, optimize=0, diff --git a/strix/interface/main.py b/strix/interface/main.py index ceb14c26..3ba3a5b2 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -6,6 +6,7 @@ Strix Agent Interface import argparse import asyncio import contextlib +import logging import os import sys from pathlib import Path @@ -42,7 +43,23 @@ from strix.interface.utils import ( build_final_stats_text, ) from strix.telemetry import posthog, scarf -from strix.telemetry.logging import configure_dependency_logging +from strix.telemetry.logging import ( + attach_preflight_logging, + configure_dependency_logging, + debug_logging_enabled, +) + + +# Frozen (PyInstaller) binaries need the bundled certifi CA path exported so +# httpx/requests verify TLS against a real cacert.pem inside the archive. +# The PyInstaller runtime hook covers the official build; this is an extra +# safety net for any frozen entry that loads this module. +if getattr(sys, "frozen", False): + import certifi + + _ca_bundle = certifi.where() + os.environ.setdefault("SSL_CERT_FILE", _ca_bundle) + os.environ.setdefault("REQUESTS_CA_BUNDLE", _ca_bundle) BEDROCK_MODEL_PREFIX = "bedrock/" @@ -57,9 +74,6 @@ VERTEX_EXTRA_HINT = ( ) -import logging # noqa: E402 - - logger = logging.getLogger(__name__) @@ -352,16 +366,30 @@ def _print_error_panel(title: str, message: str) -> None: console.print() +def _format_connection_error_detail(exc: BaseException) -> str: + """Return the user-facing error detail for a model connection failure. + + With ``STRIX_DEBUG`` enabled, include the full ``__cause__`` / + ``__context__`` chain so wrapped TLS failures (e.g. + ``SSLCertVerificationError`` under litellm/httpx ``Connection error``) + are visible. + """ + if debug_logging_enabled(): + return " | ".join(_exception_messages(exc)) + return str(exc) + + def _print_model_connection_error(exc: BaseException, model_name: str) -> None: console = Console() error_text = Text() + detail = _format_connection_error_detail(exc) sub_hint = _subscription_error_hint(exc) if sub_hint is not None: border_style = "yellow" error_text.append("MODEL NOT AVAILABLE ON SUBSCRIPTION", style="bold yellow") error_text.append("\n\n", style="white") error_text.append(f"{sub_hint}\n", style="white") - error_text.append(f"\nDetails: {exc}", style="dim white") + error_text.append(f"\nDetails: {detail}", style="dim white") else: border_style = "red" error_text.append("LLM CONNECTION FAILED", style="bold red") @@ -371,7 +399,7 @@ def _print_model_connection_error(exc: BaseException, model_name: str) -> None: hint = _provider_import_hint(exc, model_name) if hint is not None: error_text.append(f"\n{hint}\n", style="bold yellow") - error_text.append(f"\nError: {exc}", style="dim white") + error_text.append(f"\nError: {detail}", style="dim white") panel = Panel( error_text, @@ -395,6 +423,9 @@ def _bootstrap_scan(args: argparse.Namespace) -> None: validate_environment() if not args.non_interactive: return + # Preflight runs before prepare_run()/setup_scan_logging(), so attach a + # stderr handler now or STRIX_DEBUG=1 never shows warm-up failures. + attach_preflight_logging() try: asyncio.run(warm_up_llm(show_model_warning=True)) except ModelConnectionError as exc: diff --git a/strix/telemetry/logging.py b/strix/telemetry/logging.py index 75265e33..dad9255b 100644 --- a/strix/telemetry/logging.py +++ b/strix/telemetry/logging.py @@ -116,6 +116,58 @@ def _silence_urllib3_finalizer_noise() -> None: sys.unraisablehook = hook +_DEBUG_ENV_TRUTHY = frozenset({"1", "true", "yes", "on"}) +_PREFLIGHT_HANDLER_TAG = "_strix_preflight_handler" + + +def debug_logging_enabled(*, debug: bool | None = None) -> bool: + """Resolve whether Strix debug logging is on. + + ``None`` (default) reads ``STRIX_DEBUG``: ``1`` / ``true`` / ``yes`` / + ``on`` (case-insensitive) enables debug. + """ + if debug is not None: + return debug + return (os.environ.get("STRIX_DEBUG") or "").strip().lower() in _DEBUG_ENV_TRUTHY + + +def attach_preflight_logging(*, debug: bool | None = None) -> None: + """Attach a stderr-only handler so LLM preflight logs are visible early. + + ``warm_up_llm`` runs before ``setup_scan_logging`` (which needs a run + directory). Without this, ``STRIX_DEBUG=1`` still produces no output for + preflight failures. + """ + configure_dependency_logging() + enabled = debug_logging_enabled(debug=debug) + level = logging.DEBUG if enabled else logging.ERROR + + formatter = logging.Formatter(_FORMAT, datefmt=_DATEFMT) + context_filter = _StrixContextFilter() + + stream_handler = logging.StreamHandler() + stream_handler.setLevel(level) + stream_handler.setFormatter(formatter) + stream_handler.addFilter(context_filter) + stream_handler.addFilter(_StdoutQuietFilter()) + setattr(stream_handler, _PREFLIGHT_HANDLER_TAG, True) + + for name in _TRACKED_ROOTS: + tracked = logging.getLogger(name) + # Replace a previous preflight handler so repeated calls stay idempotent. + for handler in list(tracked.handlers): + if getattr(handler, _PREFLIGHT_HANDLER_TAG, False): + tracked.removeHandler(handler) + with contextlib.suppress(Exception): + handler.close() + tracked.setLevel(logging.DEBUG) + tracked.addHandler(stream_handler) + tracked.propagate = False + + for name in _NOISY_LIBS: + logging.getLogger(name).setLevel(logging.WARNING) + + def setup_scan_logging(run_dir: Path, *, debug: bool | None = None) -> Callable[[], None]: """Attach scan-scoped handlers; return a teardown callable. @@ -134,13 +186,7 @@ def setup_scan_logging(run_dir: Path, *, debug: bool | None = None) -> Callable[ """ configure_dependency_logging() - if debug is None: - debug = (os.environ.get("STRIX_DEBUG") or "").strip().lower() in { - "1", - "true", - "yes", - "on", - } + debug = debug_logging_enabled(debug=debug) run_dir.mkdir(parents=True, exist_ok=True) log_path = run_dir / "strix.log" diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 6e3a2261..f680ece7 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -9,6 +9,8 @@ import pytest PROJECT_ROOT = Path(__file__).resolve().parents[1] +SPEC_PATH = PROJECT_ROOT / "strix.spec" +CERTIFI_RTHOOK = PROJECT_ROOT / "hooks" / "rthooks" / "pyi_rth_certifi.py" def test_wheel_build_requires_go(tmp_path: Path) -> None: @@ -29,3 +31,15 @@ def test_wheel_build_requires_go(tmp_path: Path) -> None: assert result.returncode != 0 assert "Go 1.24 or newer is required" in result.stdout + result.stderr + + +def test_pyinstaller_spec_bundles_certifi_ca_and_runtime_hook() -> None: + spec = SPEC_PATH.read_text(encoding="utf-8") + assert "collect_data_files('certifi')" in spec + assert "pyi_rth_certifi.py" in spec + assert "runtime_hooks=[" in spec + assert CERTIFI_RTHOOK.is_file() + hook = CERTIFI_RTHOOK.read_text(encoding="utf-8") + assert "SSL_CERT_FILE" in hook + assert "REQUESTS_CA_BUNDLE" in hook + assert "certifi.where()" in hook diff --git a/tests/test_preflight_logging.py b/tests/test_preflight_logging.py new file mode 100644 index 00000000..caee9a84 --- /dev/null +++ b/tests/test_preflight_logging.py @@ -0,0 +1,80 @@ +"""Tests for exception-chain helpers and preflight debug logging.""" + +from __future__ import annotations + +import logging +import ssl +from typing import TYPE_CHECKING + +from strix.interface.main import ( + _exception_messages, + _format_connection_error_detail, +) +from strix.telemetry import logging as tlog +from strix.telemetry.logging import attach_preflight_logging, debug_logging_enabled + + +if TYPE_CHECKING: + import pytest + + +def _remove_preflight_handlers() -> None: + for tracked_name in ("strix", "openai.agents"): + tracked = logging.getLogger(tracked_name) + for handler in list(tracked.handlers): + if getattr(handler, tlog._PREFLIGHT_HANDLER_TAG, False): + tracked.removeHandler(handler) + handler.close() + + +def test_exception_messages_walks_cause_chain_to_ssl_error() -> None: + root = ssl.SSLCertVerificationError("certificate verify failed") + middle = ConnectionError("TLS handshake failed") + middle.__cause__ = root + exc = ConnectionError("Connection error.") + exc.__cause__ = middle + + messages = _exception_messages(exc) + + assert "Connection error." in messages + assert "TLS handshake failed" in messages + assert any("certificate verify failed" in message for message in messages) + + +def test_format_connection_error_detail_includes_chain_when_debug( + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = ssl.SSLCertVerificationError("certificate verify failed") + exc = ConnectionError("Connection error.") + exc.__cause__ = root + + monkeypatch.delenv("STRIX_DEBUG", raising=False) + assert _format_connection_error_detail(exc) == "Connection error." + + monkeypatch.setenv("STRIX_DEBUG", "1") + detail = _format_connection_error_detail(exc) + assert "Connection error." in detail + assert "certificate verify failed" in detail + + +def test_debug_logging_enabled_reads_strix_debug(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("STRIX_DEBUG", raising=False) + assert debug_logging_enabled() is False + assert debug_logging_enabled(debug=True) is True + + monkeypatch.setenv("STRIX_DEBUG", "yes") + assert debug_logging_enabled() is True + + +def test_attach_preflight_logging_emits_debug_to_stderr( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("STRIX_DEBUG", "1") + try: + attach_preflight_logging() + logging.getLogger("strix").debug("LLM warm-up failed") + captured = capsys.readouterr() + assert "LLM warm-up failed" in captured.err + finally: + _remove_preflight_handlers()