mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description On requests large enough to trigger compression, the proxy emitted an upstream Anthropic request whose `messages[0]` had `role: "system"`. Anthropic's Messages API rejects any `system` role inside `messages[]`: ``` 400 invalid_request_error: "messages.0: use the top-level 'system' parameter for the initial system prompt" ``` The original request correctly carries its system prompt in the top-level `system` parameter; a compression/transform/pipeline step relocates the harness system block into `messages[0]`, so the request fails outright (intermittent only because it requires a context large enough to compress). This adds a wire-contract guard in the Anthropic forwarder: as the **last** step before sending upstream (after every transform, memory injection, tool sort, and pipeline extension, covering both the Bedrock and direct paths), any stray `role="system"` message is relocated out of `messages[]` and merged back into the top-level `system` parameter. Content order is preserved (existing system first, relocated content after) and block-level `cache_control` survives. The guard is a no-op on the common path (no system-role entry → inputs pass through unchanged). Closes #765 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/helpers.py`: new pure helper `relocate_system_messages_to_top_level(messages, system) -> (clean_messages, new_system, changed)` plus `_system_message_to_blocks`. Handles `system` being `None`/`str`/`list`, never drops content, preserves order and content blocks. - `headroom/proxy/handlers/anthropic.py`: invoke the guard just before the byte-faithful forward block; on relocation, update `body["messages"]`/`body["system"]`, mark the body mutated (`system_role_relocated`) so the byte-faithful forwarder re-serializes, and log a warning. - `tests/test_proxy_handler_helpers.py`: 3 unit tests (relocate stray system into top-level, append-to-existing-system order, no-op without a system entry). - `CHANGELOG.md`: Bug Fixes entry. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py -q 29 passed in 4.95s # Regression on the forward path (byte-faithful forwarding, system-prompt immutability, cache stability): $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py tests/test_proxy_byte_faithful_forwarding.py tests/test_proxy_system_prompt_immutable.py tests/test_proxy_anthropic_cache_stability.py -q 90 passed, 15 warnings in 29.72s $ uv run ruff check headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py All checks passed! $ uv run ruff format --check ... # 3 files already formatted $ uv run mypy headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py Success: no issues found in 2 source files ``` ## Test verification (RED → GREEN) The new tests exercise the guard directly and import the new helper at module top, so reverting the production fix makes them fail at collection. **RED — production fix reverted (helper removed):** ```text ImportError while importing test module 'tests/test_proxy_handler_helpers.py'. E ImportError: cannot import name 'relocate_system_messages_to_top_level' from 'headroom.proxy.helpers' =========================== 1 error in 0.41s =============================== ``` **GREEN — production fix applied:** ```text tests/test_proxy_handler_helpers.py ... [100%] ======================= 3 passed, 26 deselected in 1.50s ======================= ``` ## Real Behavior Proof - Environment: Python 3.13, `uv run` in this repo, branch `fix/issue-765`. - Exact command / steps: ran the guard on a body in the exact #765 failure shape — `system: None` and a `role="system"` harness block at `messages[0]`: - Observed result: ```text BEFORE: messages[0].role = system (Anthropic 400 trigger) changed = True AFTER roles = ['user', 'assistant'] system param = [{"type": "text", "text": "You are Claude Code. <system-reminder>...</system-reminder>"}] OK: no role=system in messages[]; system content preserved in top-level param ``` The illegal `role="system"` entry is removed from `messages[]` and its content lands in the top-level `system` parameter — exactly the body Anthropic accepts. - Not tested: a full live 250k+-token Claude Code session against the real Anthropic API (needs a large live context + API key); the fix is validated at the request-shaping boundary the 400 is raised on. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes The guard intentionally fires at the forwarder boundary rather than in any single transform: the issue's captures show the relocation can originate from the compression path, and pipeline extensions / hooks can also mutate `messages` late. Enforcing Anthropic's wire contract once, at the point the body is serialized upstream, fixes the 400 regardless of which step introduced the stray entry and matches the architecture invariant "never produce a `system`-role entry within `messages[]`". --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
312 lines
13 KiB
Python
312 lines
13 KiB
Python
"""Unit tests for headroom.proxy.ssl_context.find_ca_bundle.
|
|
|
|
Covers:
|
|
- Returns None when no env var is set
|
|
- Returns an ssl.SSLContext when SSL_CERT_FILE points to a valid PEM file
|
|
- Returns an ssl.SSLContext when REQUESTS_CA_BUNDLE points to a valid PEM file
|
|
- Replacement contexts relax OpenSSL VERIFY_X509_STRICT for custom CA bundles
|
|
- Returns an ssl.SSLContext when NODE_EXTRA_CA_CERTS points to a valid PEM file
|
|
- The NODE_EXTRA_CA_CERTS SSLContext is additive: default/system roots are preserved (#998)
|
|
- Priority order: SSL_CERT_FILE beats REQUESTS_CA_BUNDLE beats NODE_EXTRA_CA_CERTS
|
|
- Nonexistent paths are skipped (returns None if all paths are missing)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ssl
|
|
|
|
import pytest
|
|
|
|
from headroom.proxy import ssl_context
|
|
from headroom.proxy.ssl_context import (
|
|
apply_global_tls_relaxation,
|
|
build_httpx_verify,
|
|
find_ca_bundle,
|
|
tls_strict_disabled,
|
|
)
|
|
|
|
# Minimal self-signed CA certificate (PEM) used only to verify that
|
|
# load_verify_locations accepts the file. Generated offline; never used
|
|
# for real TLS handshakes in these tests.
|
|
_SELF_SIGNED_CA_PEM = b"""\
|
|
-----BEGIN CERTIFICATE-----
|
|
MIIDFzCCAf+gAwIBAgIUWP49K8QzU5B68/BZSmeqPCDaBoQwDQYJKoZIhvcNAQEL
|
|
BQAwGzEZMBcGA1UEAwwQaGVhZHJvb20tdGVzdC1jYTAeFw0yNjA2MDgxNDIwMzFa
|
|
Fw0zNjA2MDUxNDIwMzFaMBsxGTAXBgNVBAMMEGhlYWRyb29tLXRlc3QtY2EwggEi
|
|
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvTqYZXAhet9yw1n4cFeC8HosC
|
|
1Od/bibXyW7ko7aOuuzUT7B9l7MwDfgrE2mjHecoSe2qbknFcv6hxbYojh4J7C8r
|
|
UPgCA2QTtU3pBxQdwO156YAOmFPuBFPb19NAErOVlnHCU+NXCVSsE5y+AJjM161S
|
|
W0HnZgO8OADZHBs5jSAGDE3ymMw+8xpuvRKJnuvK0Tcu6bOqOTMbnggwmPBZBBLW
|
|
PrurPTN0vV9C2oyHA1tXgEJyYtEPoMfaqyE80GxYeUujt9EQWrLp+3k8ufB/yJ1b
|
|
DaSrH0GZYx2HUn0p1mqWzXcKZrSrL1o+38gCmCivG0movXt6z1tUly8mTGz/AgMB
|
|
AAGjUzBRMB0GA1UdDgQWBBTyJ8OWE/bpWbKM3SB52P+9DhGN/TAfBgNVHSMEGDAW
|
|
gBTyJ8OWE/bpWbKM3SB52P+9DhGN/TAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
|
|
DQEBCwUAA4IBAQAb44h2gg9wWU5todvwSXVAlBb/WZD1l/NG2PeTsGoH7xqmfgq9
|
|
DxV6tvoIuDlu6OKz071ljSqRh0Mesh1ma1cj6snsc/jqgsakSlcOpOCsrTCvw2DB
|
|
2oTztHnO4PiZAPtuKiawhVQpJfEna9/xOkbalazecSGngtSzd/oIJEXe299hE1/1
|
|
Tfx2hBGZ0UogmREaXFi099rmaueZ0HIBn51b3kYqc7of5TI0fHwSHF4GdXXs2OZi
|
|
6EVQWhKx5nQbklTYP5/ge9olEIsMdGqJEiz7WfSC6QBBgvoYyH596GiSGRZcX67p
|
|
kF9agIt8Q8t/2kviMn2roInGTwTyPYOEQV0m
|
|
-----END CERTIFICATE-----
|
|
"""
|
|
_TEST_CA_COUNT = 1
|
|
|
|
|
|
@pytest.fixture()
|
|
def ca_pem_file(tmp_path):
|
|
"""Write the self-signed CA PEM to a temp file and return its path."""
|
|
p = tmp_path / "ca.pem"
|
|
p.write_bytes(_SELF_SIGNED_CA_PEM)
|
|
return str(p)
|
|
|
|
|
|
def _clean_env(monkeypatch):
|
|
"""Remove all CA-bundle env vars + the strict toggle for a clean state."""
|
|
for var in (
|
|
"SSL_CERT_FILE",
|
|
"REQUESTS_CA_BUNDLE",
|
|
"NODE_EXTRA_CA_CERTS",
|
|
"HEADROOM_TLS_STRICT",
|
|
):
|
|
monkeypatch.delenv(var, raising=False)
|
|
|
|
|
|
def _default_x509_ca_count() -> int:
|
|
return ssl.create_default_context().cert_store_stats()["x509_ca"]
|
|
|
|
|
|
class FakeSSLContext:
|
|
def __init__(self, verify_flags: int = 0) -> None:
|
|
self.verify_flags = verify_flags
|
|
self.loaded_cafile: str | None = None
|
|
self.alpn_protocols: list[str] | None = None
|
|
|
|
def load_verify_locations(self, *, cafile: str) -> None:
|
|
self.loaded_cafile = cafile
|
|
|
|
def set_alpn_protocols(self, protocols: list[str]) -> None:
|
|
self.alpn_protocols = protocols
|
|
|
|
|
|
class TestFindCaBundleNoEnvVars:
|
|
def test_returns_none_when_no_env_var_set(self, monkeypatch):
|
|
_clean_env(monkeypatch)
|
|
assert find_ca_bundle() is None
|
|
|
|
|
|
class TestFindCaBundleWithValidPem:
|
|
def test_ssl_cert_file_returns_ssl_context(self, monkeypatch, ca_pem_file):
|
|
_clean_env(monkeypatch)
|
|
monkeypatch.setenv("SSL_CERT_FILE", ca_pem_file)
|
|
ctx = find_ca_bundle()
|
|
assert isinstance(ctx, ssl.SSLContext)
|
|
|
|
def test_requests_ca_bundle_returns_ssl_context(self, monkeypatch, ca_pem_file):
|
|
_clean_env(monkeypatch)
|
|
monkeypatch.setenv("REQUESTS_CA_BUNDLE", ca_pem_file)
|
|
ctx = find_ca_bundle()
|
|
assert isinstance(ctx, ssl.SSLContext)
|
|
|
|
def test_replacement_ca_context_relaxes_x509_strict(self, monkeypatch, ca_pem_file):
|
|
_clean_env(monkeypatch)
|
|
monkeypatch.setenv("SSL_CERT_FILE", ca_pem_file)
|
|
strict_flag = 0x20
|
|
created_context = FakeSSLContext(verify_flags=strict_flag | 0x100)
|
|
|
|
def fake_create_default_context(*, cafile: str | None = None):
|
|
assert cafile == ca_pem_file
|
|
return created_context
|
|
|
|
monkeypatch.setattr(ssl_context.ssl, "VERIFY_X509_STRICT", strict_flag, raising=False)
|
|
monkeypatch.setattr(ssl_context.ssl, "create_default_context", fake_create_default_context)
|
|
|
|
ctx = find_ca_bundle()
|
|
|
|
assert ctx is created_context
|
|
assert created_context.verify_flags & strict_flag == 0
|
|
assert created_context.verify_flags & 0x100
|
|
assert created_context.alpn_protocols == ["h2", "http/1.1"]
|
|
|
|
def test_node_extra_ca_certs_returns_ssl_context(self, monkeypatch, ca_pem_file):
|
|
"""NODE_EXTRA_CA_CERTS returns an SSLContext, not a bare path (#998)."""
|
|
_clean_env(monkeypatch)
|
|
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", ca_pem_file)
|
|
ctx = find_ca_bundle()
|
|
assert isinstance(ctx, ssl.SSLContext)
|
|
|
|
def test_node_extra_ca_certs_is_additive(self, monkeypatch, ca_pem_file):
|
|
"""The SSLContext must contain default/system roots plus the extra cert (#998)."""
|
|
_clean_env(monkeypatch)
|
|
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", ca_pem_file)
|
|
ctx = find_ca_bundle()
|
|
assert isinstance(ctx, ssl.SSLContext)
|
|
stats = ctx.cert_store_stats()
|
|
# Additive loading preserves whatever the runner's default trust store
|
|
# contains. Some minimal CI images have a tiny or empty default store, so
|
|
# compare against the local baseline instead of assuming "dozens" of CAs.
|
|
assert stats["x509_ca"] >= _default_x509_ca_count() + _TEST_CA_COUNT
|
|
|
|
|
|
class TestFindCaBundlePriority:
|
|
def test_ssl_cert_file_beats_requests_ca_bundle(self, monkeypatch, tmp_path):
|
|
"""SSL_CERT_FILE is used first even when REQUESTS_CA_BUNDLE is also set."""
|
|
_clean_env(monkeypatch)
|
|
pem1 = tmp_path / "first.pem"
|
|
pem2 = tmp_path / "second.pem"
|
|
pem1.write_bytes(_SELF_SIGNED_CA_PEM)
|
|
pem2.write_bytes(_SELF_SIGNED_CA_PEM)
|
|
monkeypatch.setenv("SSL_CERT_FILE", str(pem1))
|
|
monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(pem2))
|
|
created_context = FakeSSLContext()
|
|
|
|
def fake_create_default_context(*, cafile: str | None = None):
|
|
assert cafile == str(pem1)
|
|
return created_context
|
|
|
|
monkeypatch.setattr(ssl_context.ssl, "create_default_context", fake_create_default_context)
|
|
|
|
assert find_ca_bundle() is created_context
|
|
|
|
def test_ssl_cert_file_beats_node_extra_ca_certs(self, monkeypatch, tmp_path):
|
|
"""SSL_CERT_FILE takes precedence over NODE_EXTRA_CA_CERTS."""
|
|
_clean_env(monkeypatch)
|
|
pem = tmp_path / "ca.pem"
|
|
pem.write_bytes(_SELF_SIGNED_CA_PEM)
|
|
monkeypatch.setenv("SSL_CERT_FILE", str(pem))
|
|
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "/nonexistent/node.pem")
|
|
created_context = FakeSSLContext()
|
|
|
|
def fake_create_default_context(*, cafile: str | None = None):
|
|
assert cafile == str(pem)
|
|
return created_context
|
|
|
|
monkeypatch.setattr(ssl_context.ssl, "create_default_context", fake_create_default_context)
|
|
|
|
assert find_ca_bundle() is created_context
|
|
|
|
def test_requests_ca_bundle_beats_node_extra_ca_certs(self, monkeypatch, tmp_path):
|
|
"""REQUESTS_CA_BUNDLE is used before NODE_EXTRA_CA_CERTS."""
|
|
_clean_env(monkeypatch)
|
|
pem = tmp_path / "ca.pem"
|
|
pem.write_bytes(_SELF_SIGNED_CA_PEM)
|
|
monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/ssl.pem")
|
|
monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(pem))
|
|
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "/nonexistent/node.pem")
|
|
created_context = FakeSSLContext()
|
|
|
|
def fake_create_default_context(*, cafile: str | None = None):
|
|
assert cafile == str(pem)
|
|
return created_context
|
|
|
|
monkeypatch.setattr(ssl_context.ssl, "create_default_context", fake_create_default_context)
|
|
|
|
assert find_ca_bundle() is created_context
|
|
|
|
|
|
class TestFindCaBundleNonexistentPaths:
|
|
def test_nonexistent_path_is_skipped(self, monkeypatch):
|
|
_clean_env(monkeypatch)
|
|
monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/path/ca.pem")
|
|
assert find_ca_bundle() is None
|
|
|
|
def test_all_nonexistent_returns_none(self, monkeypatch):
|
|
_clean_env(monkeypatch)
|
|
monkeypatch.setenv("SSL_CERT_FILE", "/no/such/file1.pem")
|
|
monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/no/such/file2.pem")
|
|
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "/no/such/file3.pem")
|
|
assert find_ca_bundle() is None
|
|
|
|
def test_first_nonexistent_falls_through_to_valid(self, monkeypatch, ca_pem_file):
|
|
"""When the first env var path is missing, the next valid one is used."""
|
|
_clean_env(monkeypatch)
|
|
monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/ssl.pem")
|
|
monkeypatch.setenv("REQUESTS_CA_BUNDLE", ca_pem_file)
|
|
|
|
ctx = find_ca_bundle()
|
|
|
|
assert isinstance(ctx, ssl.SSLContext)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HEADROOM_TLS_STRICT toggle (issue #1308): corporate TLS-inspection roots
|
|
# (Zscaler, Netskope) set CA:TRUE without the critical bit, which Python 3.13
|
|
# + OpenSSL 3.x reject under VERIFY_X509_STRICT. A CA bundle can't fix that —
|
|
# the cert is found, the strict check fails. The toggle clears only the strict
|
|
# flag, on both the httpx upstream path and the urllib3/huggingface path.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTlsStrictDisabled:
|
|
@pytest.mark.parametrize("val", ["0", "false", "FALSE", "No", "off", " off "])
|
|
def test_off_values_disable_strict(self, monkeypatch, val):
|
|
_clean_env(monkeypatch)
|
|
monkeypatch.setenv("HEADROOM_TLS_STRICT", val)
|
|
assert tls_strict_disabled() is True
|
|
|
|
@pytest.mark.parametrize("val", ["1", "true", "yes", "on", "", "strict", "00"])
|
|
def test_other_values_keep_strict(self, monkeypatch, val):
|
|
_clean_env(monkeypatch)
|
|
monkeypatch.setenv("HEADROOM_TLS_STRICT", val)
|
|
assert tls_strict_disabled() is False
|
|
|
|
def test_unset_keeps_strict(self, monkeypatch):
|
|
_clean_env(monkeypatch)
|
|
assert tls_strict_disabled() is False
|
|
|
|
|
|
class TestBuildHttpxVerify:
|
|
def test_default_returns_true(self, monkeypatch):
|
|
"""No CA bundle, strict on → httpx's own default verification."""
|
|
_clean_env(monkeypatch)
|
|
assert build_httpx_verify() is True
|
|
|
|
def test_toggle_off_returns_relaxed_context(self, monkeypatch):
|
|
"""No CA bundle, strict OFF → default trust store with strict cleared."""
|
|
_clean_env(monkeypatch)
|
|
monkeypatch.setenv("HEADROOM_TLS_STRICT", "0")
|
|
ctx = build_httpx_verify()
|
|
assert isinstance(ctx, ssl.SSLContext)
|
|
strict_flag = getattr(ssl, "VERIFY_X509_STRICT", 0)
|
|
if strict_flag:
|
|
assert ctx.verify_flags & strict_flag == 0
|
|
# Still a real verifying context — NOT verify=False.
|
|
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
|
# Default trust store retained for this runner, not replaced by a custom
|
|
# one-cert bundle.
|
|
assert ctx.cert_store_stats()["x509_ca"] == _default_x509_ca_count()
|
|
|
|
def test_custom_ca_takes_precedence_over_toggle(self, monkeypatch, ca_pem_file):
|
|
"""A configured CA bundle wins; the result is that bundle's context."""
|
|
_clean_env(monkeypatch)
|
|
monkeypatch.setenv("SSL_CERT_FILE", ca_pem_file)
|
|
monkeypatch.setenv("HEADROOM_TLS_STRICT", "0")
|
|
ctx = build_httpx_verify()
|
|
assert isinstance(ctx, ssl.SSLContext)
|
|
# Replacement bundle → only the single test CA is trusted.
|
|
assert ctx.cert_store_stats()["x509_ca"] == 1
|
|
|
|
|
|
class TestApplyGlobalTlsRelaxation:
|
|
def test_noop_when_strict_on(self, monkeypatch):
|
|
_clean_env(monkeypatch)
|
|
assert apply_global_tls_relaxation() is False
|
|
|
|
def test_patches_urllib3_when_toggle_off(self, monkeypatch):
|
|
_clean_env(monkeypatch)
|
|
monkeypatch.setenv("HEADROOM_TLS_STRICT", "0")
|
|
strict_flag = getattr(ssl, "VERIFY_X509_STRICT", 0)
|
|
if not strict_flag:
|
|
pytest.skip("VERIFY_X509_STRICT unavailable on this OpenSSL build")
|
|
|
|
import urllib3.util.ssl_ as u3ssl
|
|
|
|
original = u3ssl.create_urllib3_context
|
|
try:
|
|
assert apply_global_tls_relaxation() is True
|
|
ctx = u3ssl.create_urllib3_context()
|
|
assert ctx.verify_flags & strict_flag == 0
|
|
# Idempotent: second call doesn't re-wrap or error.
|
|
assert apply_global_tls_relaxation() is True
|
|
assert getattr(u3ssl.create_urllib3_context, "_headroom_strict_relaxed", False)
|
|
finally:
|
|
u3ssl.create_urllib3_context = original
|