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
This commit is contained in:
not-knope
2026-08-07 15:46:29 +02:00
parent 0607abf9e5
commit 78d92ddd83
7 changed files with 210 additions and 14 deletions
+20
View File
@@ -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)
+2
View File
@@ -232,6 +232,8 @@ ignore = [
"tests/test_codex_auth.py" = ["S105", "S106", "SLF001"] "tests/test_codex_auth.py" = ["S105", "S106", "SLF001"]
# Hatchling loads the build hook by path, not as an importable package. # Hatchling loads the build hook by path, not as an importable package.
"scripts/tui_sidecar_hook.py" = ["INP001"] "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). # Stdlib HTTP handler overrides (do_GET/do_POST).
"strix/interface/auth_cli.py" = ["N802"] "strix/interface/auth_cli.py" = ["N802"]
"tests/test_codex_streaming.py" = ["N802"] "tests/test_codex_streaming.py" = ["N802"]
+4 -1
View File
@@ -40,6 +40,9 @@ datas += collect_data_files('tiktoken')
datas += collect_data_files('tiktoken_ext') datas += collect_data_files('tiktoken_ext')
datas += collect_data_files('litellm') 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']) datas += collect_data_files('agents', includes=['**/*.md', '**/*.jinja', '**/*.json'])
@@ -251,7 +254,7 @@ a = Analysis(
hiddenimports=hiddenimports, hiddenimports=hiddenimports,
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[str(project_root / 'hooks' / 'rthooks' / 'pyi_rth_certifi.py')],
excludes=excludes, excludes=excludes,
noarchive=False, noarchive=False,
optimize=0, optimize=0,
+37 -6
View File
@@ -6,6 +6,7 @@ Strix Agent Interface
import argparse import argparse
import asyncio import asyncio
import contextlib import contextlib
import logging
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -42,7 +43,23 @@ from strix.interface.utils import (
build_final_stats_text, build_final_stats_text,
) )
from strix.telemetry import posthog, scarf 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/" BEDROCK_MODEL_PREFIX = "bedrock/"
@@ -57,9 +74,6 @@ VERTEX_EXTRA_HINT = (
) )
import logging # noqa: E402
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -352,16 +366,30 @@ def _print_error_panel(title: str, message: str) -> None:
console.print() 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: def _print_model_connection_error(exc: BaseException, model_name: str) -> None:
console = Console() console = Console()
error_text = Text() error_text = Text()
detail = _format_connection_error_detail(exc)
sub_hint = _subscription_error_hint(exc) sub_hint = _subscription_error_hint(exc)
if sub_hint is not None: if sub_hint is not None:
border_style = "yellow" border_style = "yellow"
error_text.append("MODEL NOT AVAILABLE ON SUBSCRIPTION", style="bold yellow") error_text.append("MODEL NOT AVAILABLE ON SUBSCRIPTION", style="bold yellow")
error_text.append("\n\n", style="white") error_text.append("\n\n", style="white")
error_text.append(f"{sub_hint}\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: else:
border_style = "red" border_style = "red"
error_text.append("LLM CONNECTION FAILED", style="bold 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) hint = _provider_import_hint(exc, model_name)
if hint is not None: if hint is not None:
error_text.append(f"\n{hint}\n", style="bold yellow") 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( panel = Panel(
error_text, error_text,
@@ -395,6 +423,9 @@ def _bootstrap_scan(args: argparse.Namespace) -> None:
validate_environment() validate_environment()
if not args.non_interactive: if not args.non_interactive:
return 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: try:
asyncio.run(warm_up_llm(show_model_warning=True)) asyncio.run(warm_up_llm(show_model_warning=True))
except ModelConnectionError as exc: except ModelConnectionError as exc:
+53 -7
View File
@@ -116,6 +116,58 @@ def _silence_urllib3_finalizer_noise() -> None:
sys.unraisablehook = hook 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]: def setup_scan_logging(run_dir: Path, *, debug: bool | None = None) -> Callable[[], None]:
"""Attach scan-scoped handlers; return a teardown callable. """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() configure_dependency_logging()
if debug is None: debug = debug_logging_enabled(debug=debug)
debug = (os.environ.get("STRIX_DEBUG") or "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
run_dir.mkdir(parents=True, exist_ok=True) run_dir.mkdir(parents=True, exist_ok=True)
log_path = run_dir / "strix.log" log_path = run_dir / "strix.log"
+14
View File
@@ -9,6 +9,8 @@ import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1] 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: 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 result.returncode != 0
assert "Go 1.24 or newer is required" in result.stdout + result.stderr 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
+80
View File
@@ -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()