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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 90 additions & 2 deletions aikit
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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):
Expand Down Expand Up @@ -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 = []
Expand Down
77 changes: 77 additions & 0 deletions tests/test_aikit_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading