mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
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:
@@ -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)
|
||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user