Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- SSL certificate verification failures in PyInstaller binary on systems without python.org Python installed; bundled `certifi` CA bundle is now auto-configured via runtime hook (#429)
- Virtual package types (files, collections, subdirectories) now respect `ARTIFACTORY_ONLY=1`, matching the primary zip-archive proxy-only behavior (#418)
- `apm pack --target claude` no longer produces an empty bundle when skills/agents are installed under `.github/` -- cross-target path mapping remaps `skills/` and `agents/` to the pack target prefix (#420)

Expand Down
3 changes: 2 additions & 1 deletion build/apm.spec
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ hiddenimports = [
'pathlib',
'frontmatter',
'requests',
'certifi', # CA certificate bundle for SSL verification in frozen binary
# Rich modules (lazily imported, must be explicitly included)
'rich',
'rich.console',
Expand Down Expand Up @@ -199,7 +200,7 @@ a = Analysis(
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
runtime_hooks=[str(repo_root / 'build' / 'hooks' / 'runtime_hook_ssl_certs.py')],
excludes=excludes,
win_no_prefer_redirects=False,
win_private_assemblies=False,
Expand Down
40 changes: 40 additions & 0 deletions build/hooks/runtime_hook_ssl_certs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# PyInstaller runtime hook -- configures SSL certificate paths for the
# frozen binary so that HTTPS connections work on every platform without
# requiring the user to install Python or set environment variables.
#
# Problem: PyInstaller bundles OpenSSL, but the compiled-in certificate
# search path points at the *build machine's* Python framework directory
# (e.g. /Library/Frameworks/Python.framework/...). On end-user machines
# that path rarely exists, causing SSL verification failures.
#
# Solution: Point ``SSL_CERT_FILE`` at the certifi CA bundle shipped
# inside the frozen binary. ``requests``, ``urllib3``, and the stdlib
# ``ssl`` module all honour this variable.
#
# This hook executes before any application code so the variables are
# visible to every subsequent import.

import os
import sys


def _configure_ssl_certs() -> None:
"""Set SSL_CERT_FILE to the bundled certifi CA bundle when frozen."""
if not getattr(sys, "frozen", False):
return

# Honour explicit user overrides -- never clobber them.
if os.environ.get("SSL_CERT_FILE") or os.environ.get("REQUESTS_CA_BUNDLE"):
return

try:
import certifi
ca_bundle = certifi.where()
if os.path.isfile(ca_bundle):
os.environ["SSL_CERT_FILE"] = ca_bundle
except Exception:
# certifi unavailable or broken -- fall through to system defaults.
pass


_configure_ssl_certs()
151 changes: 151 additions & 0 deletions tests/unit/test_ssl_cert_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Tests for the PyInstaller SSL certificate runtime hook.

The hook lives at ``build/hooks/runtime_hook_ssl_certs.py`` and is executed
by PyInstaller before any application code. These tests exercise the logic
in isolation by importing the private helper directly.
"""

import importlib
import os
import sys
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

# The runtime hook is not inside a regular Python package, so we import it
# manually from its file path.
def _find_repo_root() -> Path:
"""Walk up from this file until we find pyproject.toml (the repo root)."""
current = Path(__file__).resolve().parent
for parent in [current] + list(current.parents):
if (parent / "pyproject.toml").is_file():
return parent
raise RuntimeError("Cannot locate repository root (no pyproject.toml found)")


_HOOK_PATH = _find_repo_root() / "build" / "hooks" / "runtime_hook_ssl_certs.py"


def _load_hook_module():
"""Import the runtime hook as a module.

Executes the module which defines ``_configure_ssl_certs`` *and* calls it
at module scope. Tests invoke the function again with controlled env vars
to exercise each code path independently.
"""
spec = importlib.util.spec_from_file_location("runtime_hook_ssl_certs", _HOOK_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod


def _get_configure_fn():
"""Return a fresh reference to ``_configure_ssl_certs`` from the hook."""
mod = _load_hook_module()
return mod._configure_ssl_certs


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------

class TestSSLCertRuntimeHook:
"""Tests for _configure_ssl_certs behaviour."""

def test_hook_file_exists(self):
"""The runtime hook must exist at the expected path."""
assert _HOOK_PATH.is_file(), f"Missing runtime hook: {_HOOK_PATH}"

# -- Frozen-mode gating --------------------------------------------------

def test_noop_when_not_frozen(self, monkeypatch):
"""When ``sys.frozen`` is absent, the hook must not set any env vars."""
monkeypatch.delattr(sys, "frozen", raising=False)
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)

fn = _get_configure_fn()
fn()

assert "SSL_CERT_FILE" not in os.environ

# -- User-override respect -----------------------------------------------

def test_respects_existing_ssl_cert_file(self, monkeypatch):
"""If the user already set SSL_CERT_FILE, do not overwrite it."""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setenv("SSL_CERT_FILE", "/custom/ca.pem")
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)

fn = _get_configure_fn()
fn()

assert os.environ["SSL_CERT_FILE"] == "/custom/ca.pem"

def test_respects_existing_requests_ca_bundle(self, monkeypatch):
"""If the user already set REQUESTS_CA_BUNDLE, do not set SSL_CERT_FILE."""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/custom/bundle.pem")

fn = _get_configure_fn()
fn()

assert "SSL_CERT_FILE" not in os.environ

# -- Happy path: frozen + certifi available ------------------------------

def test_sets_ssl_cert_file_when_frozen(self, monkeypatch, tmp_path):
"""In a frozen binary with certifi, SSL_CERT_FILE is set automatically."""
ca_file = tmp_path / "cacert.pem"
ca_file.write_text("--- dummy CA bundle ---")

monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)

mock_certifi = MagicMock()
mock_certifi.where.return_value = str(ca_file)

with patch.dict("sys.modules", {"certifi": mock_certifi}):
fn = _get_configure_fn()
fn()

assert os.environ.get("SSL_CERT_FILE") == str(ca_file)

# -- Fallback: certifi missing -------------------------------------------

def test_graceful_when_certifi_missing(self, monkeypatch):
"""If certifi is not importable, the hook silently continues."""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)

with patch.dict("sys.modules", {"certifi": None}):
fn = _get_configure_fn()
fn() # must not raise

assert "SSL_CERT_FILE" not in os.environ

# -- Edge case: certifi points at missing file ---------------------------

def test_skips_when_ca_file_missing(self, monkeypatch, tmp_path):
"""If certifi.where() returns a non-existent path, skip silently."""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)

mock_certifi = MagicMock()
mock_certifi.where.return_value = str(tmp_path / "does_not_exist.pem")

with patch.dict("sys.modules", {"certifi": mock_certifi}):
fn = _get_configure_fn()
fn()

assert "SSL_CERT_FILE" not in os.environ
Loading