Commit graph

5 commits

Author SHA1 Message Date
Ben Younes
9fde127534
fix(proxy): relocate stray system-role messages to the top-level system param (#765) (#1357)
## 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>
2026-08-13 11:52:09 -05:00
Lakshya Sharma
52068dd650
fix(tls): add HEADROOM_TLS_STRICT=0 toggle for corporate SSL inspection (#1308) (#1341)
## Description

Behind a corporate TLS-inspection proxy (Zscaler, Netskope) on Python
3.13+, Headroom can't reach the network even with the corporate root
correctly installed and trusted. Every path fails with:

```
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
Basic Constraints of CA cert not marked critical
```

This isn't a missing-CA problem — the cert is found and trusted. Python
3.13 + OpenSSL 3.x enable `VERIFY_X509_STRICT` by default, which
enforces RFC 5280 §4.2.1.9 (a CA cert's `basicConstraints` MUST be
marked critical). Inspection roots set `CA:TRUE` without the critical
bit, so the chain is rejected. Adding the CA to a bundle does nothing —
it's the strict check that fails, and the existing README section only
covers `unable to get local issuer certificate`.

There are two independent sources of the strict flag (both reported in
the issue): Python's own `ssl.create_default_context()` (hits the httpx
upstream client), and urllib3 ≥ 2.5's `create_urllib3_context()` (hits
the `huggingface_hub` model-download path).

Closes #1308

## 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

- `ssl_context.py`: added `HEADROOM_TLS_STRICT`. `tls_strict_disabled()`
reads the toggle (off-values `0/false/no/off`, default strict).
`build_httpx_verify()` resolves the httpx `verify=` value: a configured
CA bundle wins; otherwise, when the toggle is off, a default-trust-store
context with **only** `VERIFY_X509_STRICT` cleared (so a corporate root
that lives in the OS store but trips strict mode still validates);
otherwise `True` (httpx default). `apply_global_tls_relaxation()`
monkeypatches urllib3's `create_urllib3_context` to drop the strict flag
— idempotent, guarded, no-op if urllib3 is absent or the toggle is on.
- `server.py`: the proxy's httpx upstream client now uses
`build_httpx_verify()` instead of `find_ca_bundle()`-or-`True`.
- `cli/proxy.py`: calls `apply_global_tls_relaxation()` at module
import, before `huggingface_hub`/`requests` import and cache their
context.
- README: a distinct SSL-inspection subsection for the `Basic
Constraints ... not marked critical` failure, separate from `unable to
get local issuer certificate`. Documents that the Rust core's ONNX
download (`cdn.pyke.io`) uses a separate stack (rustls/OS trust store)
unaffected by the toggle — corporate root must be in the Windows
**machine** store, or pre-provision via `ORT_STRATEGY=system`.

Chain validation, signature, expiry, and hostname checks all stay on —
`HEADROOM_TLS_STRICT=0` is strictly narrower than `verify=False`.
Default is strict, matching Python's own default.

## Testing

- [x] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ python -m pytest tests/test_ssl_context.py -q
31 passed
# 19 existing + 12 new (TestTlsStrictDisabled, TestBuildHttpxVerify, TestApplyGlobalTlsRelaxation).
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.10.11 (OpenSSL build that exposes
`VERIFY_X509_STRICT`).
- Exact command / steps: exercised the module directly — set/unset
`HEADROOM_TLS_STRICT`, inspected the resolved httpx `verify` value and
the urllib3 context's `verify_flags`.
- Observed result: default → `verify=True` (strict preserved);
`HEADROOM_TLS_STRICT=0` → httpx gets an `SSLContext` with
`VERIFY_X509_STRICT` cleared but `verify_mode == CERT_REQUIRED` and the
full default trust store (cert_store x509_ca > 1);
`apply_global_tls_relaxation()` patches
`urllib3.util.ssl_.create_urllib3_context` so new contexts have the
strict flag cleared, and is idempotent. A configured `SSL_CERT_FILE`
still wins over the toggle.
- Not tested: an actual handshake through a live Zscaler/Netskope MITM
on Python 3.13 — I don't have that environment. The fix targets exactly
the flag the issue identifies (`VERIFY_X509_STRICT`) on both reported
context builders; I verified the flag manipulation and resolution logic
directly rather than simulating the proxy.

## 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
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

- The toggle is opt-in and defaults to strict, so behavior is unchanged
unless a user explicitly sets `HEADROOM_TLS_STRICT=0`. It clears only
the strict flag, never disables verification.
- The httpx path uses an explicit context (clean, testable); the urllib3
path needs a monkeypatch because `huggingface_hub` → `requests` builds
its context internally and never sees ours.
- CHANGELOG.md isn't touched — release-please generates it from the
`fix(tls):` commit subject.
- I scoped this to the two Python TLS stacks the issue calls out and
documented (rather than tried to patch) the separate Rust/ONNX path,
since that one resolves through the OS trust store and isn't something
this Python toggle can reach.
2026-06-24 09:51:30 -05:00
Mark Phelps
561ba17ec2
fix(proxy): build SSL contexts for custom CA bundles (#1134)
## Description

Build explicit `ssl.SSLContext` objects for custom CA bundles configured
through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer
OpenSSL can reject some enterprise/private PKI roots that platform TLS
stacks accept, for example roots without a `keyUsage` extension. The new
custom-CA contexts keep certificate verification enabled while clearing
only `ssl.VERIFY_X509_STRICT`.

Closes #

## 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

- Build replacement `SSLContext` objects for `SSL_CERT_FILE` and
`REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx.
- Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA
contexts; certificate verification, hostname verification, expiry
checks, and chain validation stay enabled.
- Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra
CA bundle on top of the default/system trust store.
- Update existing SSL context tests for replacement CA contexts, env-var
priority, missing-path fallthrough, and strict-mode relaxation.
- Add an Unreleased changelog entry for the proxy bug fix.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q
collected 12 items

tests/test_ssl_context.py ............                                   [100%]

12 passed, 1 warning in 0.11s
```

```text
$ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md
All checks passed!

$ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py
2 files already formatted
```

## Real Behavior Proof

- Environment: macOS, Python 3.13.2, Headroom checkout on this branch,
`SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed
at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is
redacted.
- Exact command / steps: Ran a Python smoke script that imports
`headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned
verifier into `httpx.AsyncClient(verify=...)`, and performs a GET
against the enterprise HTTPS endpoint that previously failed with
OpenSSL strict verification.
- Observed result: The request used an `SSLContext`, strict X.509
verification was disabled for that custom CA context, and the request
reached the upstream HTTP response:

```text
verify_type SSLContext
strict_enabled False
status 302
```

- Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy
headroom` in this local environment. The editable build currently fails
before tests run because Cargo's `ort-sys` build script cannot download
ORT prebuilt binaries due an unrelated local certificate verification
error against the ORT CDN. No dependency or lockfile changes are
included in this PR.

## 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
- [ ] 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

## Screenshots (if applicable)

N/A.

## Additional Notes

No dependencies or lockfiles changed. Documentation is unchanged because
this is a bug fix to existing custom CA environment-variable behavior
rather than a new user-facing configuration surface.

Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 18:48:19 -05:00
Shengbo_Wang
c98728363a
fix(proxy): treat NODE_EXTRA_CA_CERTS as additive, not replacement (#998) (#1031)
## Description

`find_ca_bundle()` returns the `NODE_EXTRA_CA_CERTS` path as a bare
string passed to httpx's `verify=` parameter, which makes it the sole
trust store. When that bundle contains only a private/internal root (the
common corporate setup), all public upstreams (`api.anthropic.com`,
`api.openai.com`) fail TLS verification with
`CERTIFICATE_VERIFY_FAILED`, returning 502.

This is the inverse of #741: that fix added corporate CA support, but
this regression means public CAs are no longer trusted when the extra
bundle is not a full superset of the public roots.

The fix builds an `ssl.SSLContext` via `create_default_context()` (keeps
system/default roots) then `load_verify_locations()` (adds the extra
cert), matching Node.js additive semantics. `SSL_CERT_FILE` and
`REQUESTS_CA_BUNDLE` keep their existing replacement semantics.

Closes #998

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Split `NODE_EXTRA_CA_CERTS` handling out of the replacement-semantics
loop in `find_ca_bundle()`
- When `NODE_EXTRA_CA_CERTS` is the source, return an `ssl.SSLContext`
with default roots plus the extra cert, instead of a bare path string
- Set ALPN protocols (`h2`, `http/1.1`) on the context to preserve
HTTP/2 negotiation
- Updated `test_node_extra_ca_certs_returns_path` to assert
`ssl.SSLContext` return type
- Added `test_node_extra_ca_certs_is_additive` verifying the context
contains more than just the extra cert (default roots preserved)

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] New tests added for new functionality

### Test Output

```text
tests/test_ssl_context.py::TestFindCaBundleNoEnvVars::test_returns_none_when_no_env_var_set PASSED
tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_ssl_cert_file_returns_path PASSED
tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_requests_ca_bundle_returns_path PASSED
tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_node_extra_ca_certs_returns_ssl_context PASSED
tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_node_extra_ca_certs_is_additive PASSED
tests/test_ssl_context.py::TestFindCaBundlePriority::test_ssl_cert_file_beats_requests_ca_bundle PASSED
tests/test_ssl_context.py::TestFindCaBundlePriority::test_ssl_cert_file_beats_node_extra_ca_certs PASSED
tests/test_ssl_context.py::TestFindCaBundlePriority::test_requests_ca_bundle_beats_node_extra_ca_certs PASSED
tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_nonexistent_path_is_skipped PASSED
tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_all_nonexistent_returns_none PASSED
tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_first_nonexistent_falls_through_to_valid PASSED

11 passed in 0.91s
```

## Real Behavior Proof

- Environment: Windows 11 Home 10.0.26200, Python 3.10.18
- Exact command / steps: Ran `python -m pytest tests/test_ssl_context.py
-v` after applying the fix. The new
`test_node_extra_ca_certs_is_additive` test verifies
`ctx.cert_store_stats()['x509_ca'] > 1`, confirming the default trust
store roots are preserved alongside the extra cert. If replacement
semantics were used, only the single test CA would be loaded.
- Observed result: All 11 SSL context tests pass. The additive context
reports 143 x509_ca certs (system defaults + test cert), confirming
default roots are preserved.
- Not tested: No live TLS handshake to a public upstream with a
private-only `NODE_EXTRA_CA_CERTS` bundle, but the `cert_store_stats`
assertion proves the default roots are loaded into the context.

## 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] 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

## Additional Notes

The fix aligns with the design proposed in #741 and #745's own
description (which said it "builds an SSLContext") but which the merged
implementation did not follow. The `server.py` consumer does not need
changes because httpx accepts `ssl.SSLContext` for `verify=`. ALPN
protocols are set on the context to maintain HTTP/2 negotiation parity
with httpx's internally-built contexts.
2026-06-17 11:41:49 -05:00
yehsuf
e50fbb3e0d
fix(ssl): upstream httpx client inherits SSL_CERT_FILE, REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS (#745)
Closes #741

## What

Headroom is commonly deployed alongside Claude Code, which sets
`NODE_EXTRA_CA_CERTS` to a custom CA bundle for corporate or internal
CAs. Node.js inherits this automatically; Python's `httpx` does not.
Every upstream request silently failed with `SSL:
CERTIFICATE_VERIFY_FAILED`, causing 502s and retry loops in the client.

## Changes

- New `headroom/proxy/ssl_context.py` with `build_ssl_context()` helper
that checks `SSL_CERT_FILE` → `REQUESTS_CA_BUNDLE` →
`NODE_EXTRA_CA_CERTS` (first match wins) and builds an `ssl.SSLContext`
with the custom CA bundle loaded
- `HeadroomProxy.start()` calls `build_ssl_context()` and passes the
result as `verify=` to `httpx.AsyncClient`; falls back to `verify=True`
(default httpx behaviour) when no env var is set
- Logs which env var and path was used at `INFO` level; warns on
set-but-missing paths
- 10 unit tests covering: no env var → `None`, each var returns
`SSLContext`, priority order, nonexistent paths skipped

## Priority order

1. `SSL_CERT_FILE` — standard POSIX/Python ssl override  
2. `REQUESTS_CA_BUNDLE` — standard requests/httpx convention  
3. `NODE_EXTRA_CA_CERTS` — Node.js / Claude Code convention
2026-06-08 21:10:47 -08:00