From 4d03d0b64f2d6f0edbd2958d269c45f52732da82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:10:09 +0000 Subject: [PATCH 1/4] Initial plan From bec7b686e72cbe7e09875af8a1ba3d42cd5c0ccf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:15:44 +0000 Subject: [PATCH 2/4] fix: configure SSL certificates in PyInstaller binary via runtime hook Add a PyInstaller runtime hook that sets SSL_CERT_FILE to the bundled certifi CA bundle when running as a frozen binary. This fixes SSL certificate verification failures on systems where the build machine's Python framework path does not exist (e.g. macOS without python.org Python installed). - Create build/hooks/runtime_hook_ssl_certs.py runtime hook - Register runtime hook and add certifi to hiddenimports in apm.spec - Add comprehensive unit tests for all code paths - Respects user-set SSL_CERT_FILE and REQUESTS_CA_BUNDLE overrides Fixes #420 Co-authored-by: danielmeppiel <51440732+danielmeppiel@users.noreply.github.com> Agent-Logs-Url: https://github.com/microsoft/apm/sessions/f1f91d71-7af2-48af-b7bd-3f5492a62f99 --- CHANGELOG.md | 1 + build/apm.spec | 3 +- build/hooks/runtime_hook_ssl_certs.py | 40 ++++++++ tests/unit/test_ssl_cert_hook.py | 139 ++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 build/hooks/runtime_hook_ssl_certs.py create mode 100644 tests/unit/test_ssl_cert_hook.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cd751d0..f7f24b3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 (#420) - Virtual package types (files, collections, subdirectories) now respect `ARTIFACTORY_ONLY=1`, matching the primary zip-archive proxy-only behavior (#418) ### Added diff --git a/build/apm.spec b/build/apm.spec index bfaa75c5..36da3b01 100644 --- a/build/apm.spec +++ b/build/apm.spec @@ -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', @@ -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, diff --git a/build/hooks/runtime_hook_ssl_certs.py b/build/hooks/runtime_hook_ssl_certs.py new file mode 100644 index 00000000..6736e764 --- /dev/null +++ b/build/hooks/runtime_hook_ssl_certs.py @@ -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 both ``SSL_CERT_FILE`` and ``SSL_CERT_DIR`` at the +# certifi CA bundle shipped inside the frozen binary. ``requests``, +# ``urllib3``, and the stdlib ``ssl`` module all honour these variables. +# +# 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() diff --git a/tests/unit/test_ssl_cert_hook.py b/tests/unit/test_ssl_cert_hook.py new file mode 100644 index 00000000..9543f400 --- /dev/null +++ b/tests/unit/test_ssl_cert_hook.py @@ -0,0 +1,139 @@ +"""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. +_HOOK_PATH = Path(__file__).resolve().parents[2] / "build" / "hooks" / "runtime_hook_ssl_certs.py" + + +def _load_hook_module(): + """Import the runtime hook as a module without triggering side-effects.""" + spec = importlib.util.spec_from_file_location("runtime_hook_ssl_certs", _HOOK_PATH) + mod = importlib.util.module_from_spec(spec) + # We don't exec_module() here because that would call _configure_ssl_certs() + # at module level. Instead we exec just the function definition. + 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 From 6dc078e4c8c97598fec8844d9514fbaddde39fa1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:17:01 +0000 Subject: [PATCH 3/4] fix: address code review feedback on test robustness and changelog format Co-authored-by: danielmeppiel <51440732+danielmeppiel@users.noreply.github.com> Agent-Logs-Url: https://github.com/microsoft/apm/sessions/f1f91d71-7af2-48af-b7bd-3f5492a62f99 --- CHANGELOG.md | 2 +- tests/unit/test_ssl_cert_hook.py | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7f24b3b..a4a4fe71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +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 (#420) +- 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 (#420) - Virtual package types (files, collections, subdirectories) now respect `ARTIFACTORY_ONLY=1`, matching the primary zip-archive proxy-only behavior (#418) ### Added diff --git a/tests/unit/test_ssl_cert_hook.py b/tests/unit/test_ssl_cert_hook.py index 9543f400..10b3a9d0 100644 --- a/tests/unit/test_ssl_cert_hook.py +++ b/tests/unit/test_ssl_cert_hook.py @@ -20,15 +20,27 @@ # The runtime hook is not inside a regular Python package, so we import it # manually from its file path. -_HOOK_PATH = Path(__file__).resolve().parents[2] / "build" / "hooks" / "runtime_hook_ssl_certs.py" +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 without triggering side-effects.""" + """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) - # We don't exec_module() here because that would call _configure_ssl_certs() - # at module level. Instead we exec just the function definition. spec.loader.exec_module(mod) return mod From 5b7a033409436622fd62c38061012f3a6132bb4d Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 24 Mar 2026 04:04:25 +0100 Subject: [PATCH 4/4] fix: correct SSL hook docstring and changelog PR number - Docstring claimed both SSL_CERT_FILE and SSL_CERT_DIR are set, but only SSL_CERT_FILE is configured (which is correct and sufficient) - Changelog entry referenced #420 instead of #429 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- build/hooks/runtime_hook_ssl_certs.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afb941f2..e670f8da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +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 (#420) +- 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) diff --git a/build/hooks/runtime_hook_ssl_certs.py b/build/hooks/runtime_hook_ssl_certs.py index 6736e764..4b6462d2 100644 --- a/build/hooks/runtime_hook_ssl_certs.py +++ b/build/hooks/runtime_hook_ssl_certs.py @@ -7,9 +7,9 @@ # (e.g. /Library/Frameworks/Python.framework/...). On end-user machines # that path rarely exists, causing SSL verification failures. # -# Solution: Point both ``SSL_CERT_FILE`` and ``SSL_CERT_DIR`` at the -# certifi CA bundle shipped inside the frozen binary. ``requests``, -# ``urllib3``, and the stdlib ``ssl`` module all honour these variables. +# 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.