diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eb8f36..cb66b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,15 @@ Newest entries on top, within each tool. ## aikit +### 1.15.6 — 2026-07-03 +- **Fix: detect LibreSSL / legacy OpenSSL before gateway TLS handshake failures** (POK-94). + macOS system Python (LibreSSL 2.x) and other Pythons linked against OpenSSL + < 1.1.1 fail Cloudflare-protected gateways with an opaque + `SSLV3_ALERT_HANDSHAKE_FAILURE`. Gateway HTTP clients now run a TLS preflight + and raise an actionable error (e.g. `brew install python@3.12` on macOS) instead + of the raw handshake failure. `aikit doctor` adds a **Python TLS** prerequisite + check. No change for Linux / Homebrew Python linked against OpenSSL 1.1.1+. + ### 1.15.5 — 2026-07-03 - **Fix: Ctrl-C during `aikit update` (and `install`) now exits cleanly and kills the whole update tree** (POK-85). `run()` executed each update/install command diff --git a/aikit b/aikit index 4a65cff..d8419b4 100755 --- a/aikit +++ b/aikit @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -aikit — AI Coding Agent CLI Installer & Manager v1.15.5 +aikit — AI Coding Agent CLI Installer & Manager v1.15.6 Install, update, authenticate, and manage 31 AI coding agent CLIs from one tool. Architecture: @@ -52,7 +52,7 @@ from rich.progress import Progress, SpinnerColumn, TextColumn from rich.table import Table from rich.text import Text -__version__ = "1.15.5" +__version__ = "1.15.6" import scriptkit as sk @@ -3323,6 +3323,90 @@ def _normalize_gateway_url(url): return url +# --- Python TLS preflight (LibreSSL / legacy OpenSSL) ---------------------- +def _parse_python_ssl_version(version_str): + """Parse ``ssl.OPENSSL_VERSION`` → (name, version_tuple, is_modern). + + LibreSSL (macOS system Python) and OpenSSL < 1.1.1 lack the modern ciphers + Cloudflare and other current TLS stacks require. Unknown libraries are treated + as modern so we don't false-positive on BoringSSL and similar. + """ + m = re.match(r"LibreSSL\s+([\d.]+)", version_str or "") + if m: + parts = tuple(int(x) for x in m.group(1).split(".") if x.isdigit()) + return "LibreSSL", parts, False + m = re.match(r"OpenSSL\s+([\d.]+)", version_str or "") + if m: + parts = tuple(int(x) for x in m.group(1).split(".") if x.isdigit()) + return "OpenSSL", parts, parts >= (1, 1, 1) + return "unknown", (), True + + +def _legacy_tls_hint(*, detected=""): + """Actionable guidance when Python's TLS stack is too old for modern gateways.""" + detail = f" Detected: {detected}." if detected else "" + if platform.system() == "Darwin": + return ( + "Your Python is linked against LibreSSL or OpenSSL < 1.1.1, which lacks " + "modern TLS ciphers required by Cloudflare-protected gateways. " + "Install a modern Python: brew install python@3.12" + + detail + ) + return ( + "Your Python is linked against OpenSSL < 1.1.1, which lacks modern TLS " + "ciphers required by Cloudflare-protected gateways. " + "Use Python 3.10+ linked against OpenSSL 1.1.1 or 3.x " + "(pyenv, deadsnakes, or your distro package)." + + detail + ) + + +def _python_ssl_version_str(): + import ssl + + return ssl.OPENSSL_VERSION + + +def python_tls_is_modern(): + """True when the running Python's SSL library supports modern TLS ciphers.""" + _, _, is_modern = _parse_python_ssl_version(_python_ssl_version_str()) + return is_modern + + +def require_modern_python_tls(): + """Raise :class:`AikitError` when Python's TLS stack is too old for gateways.""" + if python_tls_is_modern(): + return + raise AikitError(_legacy_tls_hint(detected=_python_ssl_version_str())) + + +def check_python_tls(): + """Doctor check for the Python TLS library backing HTTPS clients.""" + detected = _python_ssl_version_str() + if python_tls_is_modern(): + return sk.Check.ok("Python TLS", detected) + return sk.Check.fail("Python TLS", detected, _legacy_tls_hint()) + + +def _is_tls_handshake_failure(err): + """True for client-side TLS failures that usually mean a legacy SSL library.""" + lower = (err or "").lower() + return ( + "sslv3_alert_handshake_failure" in lower + or "handshake failure" in lower + or "dh key too small" in lower + or "no ciphers available" in lower + ) + + +def _gateway_tls_error(url, exc): + """Turn an ``requests`` TLS failure into a clear, actionable :class:`AikitError`.""" + err = str(exc) + if _is_tls_handshake_failure(err): + raise AikitError(_legacy_tls_hint(detected=_python_ssl_version_str())) from exc + raise AikitError(f"TLS error reaching gateway at {url} — {exc}") from exc + + # --- model discovery client ------------------------------------------------ def _gateway_auth_headers(key): """Auth headers for gateway HTTP clients. @@ -3346,9 +3430,12 @@ def _gateway_get_json(url, key, timeout=20): """ import requests + require_modern_python_tls() headers = _gateway_auth_headers(key) try: resp = requests.get(url, headers=headers, timeout=timeout) + except requests.exceptions.SSLError as e: + _gateway_tls_error(url, e) except requests.exceptions.RequestException as e: raise AikitError(f"could not reach gateway at {url} — {e}") if resp.status_code in (401, 403): @@ -4842,6 +4929,7 @@ def do_doctor() -> int: sk.check_binary("npm", required=False, hint="npm needed for npm-based agents"), sk.check_binary("python3"), sk.check_binary("pip", required=False), + check_python_tls(), ] agents: list = [] diff --git a/tests/test_aikit_gateway.py b/tests/test_aikit_gateway.py index 67315e0..bcedd48 100644 --- a/tests/test_aikit_gateway.py +++ b/tests/test_aikit_gateway.py @@ -154,6 +154,83 @@ def test_gateway_auth_headers_are_bearer_only(aikit): assert "x-api-key" not in headers +# --- Python TLS preflight (POK-94) ------------------------------------------ +@pytest.mark.parametrize("version, modern", [ + ("OpenSSL 3.0.20 7 Apr 2026", True), + ("OpenSSL 1.1.1w 11 Sep 2023", True), + ("OpenSSL 1.1.1 11 Sep 2018", True), + ("OpenSSL 1.0.2k-fips 26 Jan 2017", False), + ("LibreSSL 2.8.3", False), + ("LibreSSL 3.3.6", False), + ("BoringSSL some-build", True), +]) +def test_parse_python_ssl_version(aikit, version, modern): + name, parts, is_modern = aikit._parse_python_ssl_version(version) + assert is_modern is modern + if version.startswith("LibreSSL"): + assert name == "LibreSSL" + elif version.startswith("OpenSSL"): + assert name == "OpenSSL" + assert parts[0] >= 1 + + +def test_require_modern_python_tls_ok(aikit, monkeypatch): + monkeypatch.setattr(aikit, "_python_ssl_version_str", + lambda: "OpenSSL 3.0.20 7 Apr 2026") + aikit.require_modern_python_tls() # no raise + + +def test_require_modern_python_tls_libressl(aikit, monkeypatch): + monkeypatch.setattr(aikit, "_python_ssl_version_str", + lambda: "LibreSSL 2.8.3") + with pytest.raises(aikit.AikitError, match="modern TLS ciphers"): + aikit.require_modern_python_tls() + + +def test_require_modern_python_tls_legacy_openssl(aikit, monkeypatch): + monkeypatch.setattr(aikit, "_python_ssl_version_str", + lambda: "OpenSSL 1.0.2k-fips 26 Jan 2017") + with pytest.raises(aikit.AikitError, match="OpenSSL < 1.1.1"): + aikit.require_modern_python_tls() + + +def test_gateway_get_json_surfaces_legacy_tls_before_network(aikit, monkeypatch): + monkeypatch.setattr(aikit, "_python_ssl_version_str", + lambda: "LibreSSL 2.8.3") + + def boom(*a, **kw): + raise AssertionError("network should not be called when TLS preflight fails") + + monkeypatch.setattr("requests.get", boom) + with pytest.raises(aikit.AikitError, match="modern TLS ciphers"): + aikit._gateway_get_json("https://gw.example.com/v1/models", "sk-x") + + +def test_gateway_get_json_handshake_failure_hint(aikit, monkeypatch): + import requests + + monkeypatch.setattr(aikit, "_python_ssl_version_str", + lambda: "OpenSSL 3.0.20 7 Apr 2026") + monkeypatch.setattr(aikit, "require_modern_python_tls", lambda: None) + + def fake_get(*a, **kw): + raise requests.exceptions.SSLError( + "[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] handshake failure (_ssl.c:1006)" + ) + + monkeypatch.setattr("requests.get", fake_get) + with pytest.raises(aikit.AikitError, match="modern TLS ciphers"): + aikit._gateway_get_json("https://gw.example.com/v1/models", "sk-x") + + +def test_check_python_tls_doctor(aikit, monkeypatch): + monkeypatch.setattr(aikit, "_python_ssl_version_str", + lambda: "LibreSSL 2.8.3") + chk = aikit.check_python_tls() + assert chk.state == "fail" + assert "modern TLS ciphers" in chk.hint + + def test_gateway_get_json_rejects_invalid_key_without_x_api_key_mask(aikit, monkeypatch): """POK-88: a lenient x-api-key must not bypass Bearer enforcement.""" import requests