Skip to content

Commit cf8f3b8

Browse files
dalexsyscursoragentlfnovoclaude
authored
fix: honor ESPERANTO_SSL_* for connection tests and discovery (#1214)
* fix: honor ESPERANTO_SSL_* for connection tests and discovery Test Connection and Discover Models used raw httpx without the documented Esperanto SSL env vars, so corporate/self-signed HTTPS endpoints failed with a misleading ConnectError while chat worked. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: reject missing ESPERANTO_SSL_CA_BUNDLE instead of falling through Match Esperanto: a configured but missing CA path raises ValueError so Test Connection cannot silently disable TLS via ESPERANTO_SSL_VERIFY. Co-authored-by: Cursor <cursoragent@cursor.com> * test(credentials): let FakeAsyncClient accept httpx constructor kwargs discover_with_config now builds httpx.AsyncClient(verify=...) so the ESPERANTO_SSL_* settings are honored. The test doubles rejected any constructor argument, so the mocked requests were never captured and six discovery tests failed after merging main. Accept **kwargs like the real client does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEHJVwHQbundHuCFVHAHZS --------- Co-authored-by: dalexsys <dalexsys@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Luis Novo <lfnovo@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent bbc7d11 commit cf8f3b8

6 files changed

Lines changed: 134 additions & 12 deletions

File tree

api/credentials_service.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from open_notebook.ai.provider_registry import PROVIDERS
2626
from open_notebook.domain.credential import Credential
2727
from open_notebook.utils.encryption import get_secret_from_env
28+
from open_notebook.utils.ssl_config import httpx_verify_setting
2829
from open_notebook.utils.url_validation import (
2930
prepare_pinned_http_target,
3031
)
@@ -452,7 +453,7 @@ def models_endpoint(url: str) -> str:
452453
f"{ollama_url.rstrip('/')}/api/tags", "ollama"
453454
)
454455
headers = dict(target.headers)
455-
async with httpx.AsyncClient() as client:
456+
async with httpx.AsyncClient(verify=httpx_verify_setting()) as client:
456457
response = await client.get(
457458
target.url,
458459
headers=headers,
@@ -486,7 +487,7 @@ def models_endpoint(url: str) -> str:
486487
headers = dict(target.headers)
487488
if api_key:
488489
headers["Authorization"] = f"Bearer {api_key}"
489-
async with httpx.AsyncClient() as client:
490+
async with httpx.AsyncClient(verify=httpx_verify_setting()) as client:
490491
response = await client.get(
491492
target.url,
492493
headers=headers,
@@ -518,7 +519,7 @@ def models_endpoint(url: str) -> str:
518519
headers = dict(target.headers)
519520
headers["x-api-key"] = api_key
520521
headers["anthropic-version"] = "2023-06-01"
521-
async with httpx.AsyncClient() as client:
522+
async with httpx.AsyncClient(verify=httpx_verify_setting()) as client:
522523
response = await client.get(
523524
target.url,
524525
headers=headers,
@@ -545,7 +546,7 @@ def models_endpoint(url: str) -> str:
545546
headers = dict(target.headers)
546547
if api_key:
547548
headers["Authorization"] = f"Bearer {api_key}"
548-
async with httpx.AsyncClient() as client:
549+
async with httpx.AsyncClient(verify=httpx_verify_setting()) as client:
549550
response = await client.get(
550551
target.url,
551552
headers=headers,
@@ -575,7 +576,7 @@ def models_endpoint(url: str) -> str:
575576
target = await prepare_pinned_http_target(url, "azure")
576577
headers = dict(target.headers)
577578
headers["api-key"] = api_key
578-
async with httpx.AsyncClient() as client:
579+
async with httpx.AsyncClient(verify=httpx_verify_setting()) as client:
579580
response = await client.get(
580581
target.url,
581582
headers=headers,
@@ -607,7 +608,7 @@ def models_endpoint(url: str) -> str:
607608
if provider == "google":
608609
try:
609610
headers = {"X-Goog-Api-Key": api_key} if api_key else {}
610-
async with httpx.AsyncClient() as client:
611+
async with httpx.AsyncClient(verify=httpx_verify_setting()) as client:
611612
response = await client.get(
612613
"https://generativelanguage.googleapis.com/v1/models",
613614
headers=headers,
@@ -671,7 +672,7 @@ def models_endpoint(url: str) -> str:
671672
else:
672673
request_url = discovery_url
673674
extensions = {}
674-
async with httpx.AsyncClient() as client:
675+
async with httpx.AsyncClient(verify=httpx_verify_setting()) as client:
675676
response = await client.get(
676677
request_url,
677678
headers=headers,

docs/6-TROUBLESHOOTING/connection-issues.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ Connection error when using HTTPS endpoints
402402
Works with HTTP but fails with HTTPS
403403
```
404404

405-
**Cause:** Self-signed certificates not trusted by Python's SSL verification
405+
**Cause:** Self-signed or privately issued certificates not trusted by Python's SSL verification inside the container. Chat via Esperanto may honor `ESPERANTO_SSL_*`, but **Test Connection** and **Discover Models** used raw httpx and historically ignored those settings (fixed so they share the same env vars).
406406

407407
**Solutions:**
408408

@@ -419,13 +419,17 @@ environment:
419419
- ESPERANTO_SSL_CA_BUNDLE=/certs/ca-bundle.pem
420420
```
421421

422+
This applies to chat **and** to credential Test Connection / model discovery.
423+
422424
### Solution 2: Disable SSL Verification (Development Only)
423425
```bash
424426
# WARNING: Only use in trusted development environments
425427
# In .env:
426428
ESPERANTO_SSL_VERIFY=false
427429
```
428430

431+
Same scope: Esperanto providers, Test Connection, and Discover Models.
432+
429433
### Solution 3: Use HTTP Instead
430434
If services are on a trusted local network, HTTP is acceptable:
431435
```
@@ -435,6 +439,11 @@ Example: http://localhost:1234/v1
435439

436440
> **Security Note:** Disabling SSL verification exposes you to man-in-the-middle attacks. Always prefer custom CA bundle or HTTP on trusted networks.
437441
442+
> **Note:** SSL failures on Test Connection are often reported as a generic
443+
> "Cannot connect to server" message (httpx wraps `CERTIFICATE_VERIFY_FAILED`
444+
> as `ConnectError`). If chat or `curl -k` works but Test Connection fails,
445+
> check `ESPERANTO_SSL_CA_BUNDLE` / `ESPERANTO_SSL_VERIFY` first.
446+
438447
---
439448

440449
## Still Having Issues?

open_notebook/ai/connection_tester.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from loguru import logger
2424

2525
from open_notebook.ai.provider_registry import PROVIDERS
26+
from open_notebook.utils.ssl_config import httpx_verify_setting
2627
from open_notebook.utils.url_validation import prepare_pinned_http_target
2728

2829

@@ -115,7 +116,7 @@ async def _test_azure_connection(
115116
target = await prepare_pinned_http_target(models_url, "azure")
116117
headers = dict(target.headers)
117118
headers["api-key"] = test_api_key
118-
async with httpx.AsyncClient(timeout=10.0) as client:
119+
async with httpx.AsyncClient(timeout=10.0, verify=httpx_verify_setting()) as client:
119120
response = await client.get(
120121
target.url,
121122
headers=headers,
@@ -158,7 +159,7 @@ async def _test_ollama_connection(base_url: str) -> Tuple[bool, str]:
158159
target = await prepare_pinned_http_target(
159160
f"{base_url.rstrip('/')}/api/tags", "ollama"
160161
)
161-
async with httpx.AsyncClient(timeout=10.0) as client:
162+
async with httpx.AsyncClient(timeout=10.0, verify=httpx_verify_setting()) as client:
162163
# Try /api/tags endpoint (standard Ollama)
163164
response = await client.get(
164165
target.url,
@@ -209,7 +210,7 @@ async def _test_openai_compatible_connection(base_url: str, api_key: Optional[st
209210
if api_key:
210211
headers["Authorization"] = f"Bearer {api_key}"
211212

212-
async with httpx.AsyncClient(timeout=10.0) as client:
213+
async with httpx.AsyncClient(timeout=10.0, verify=httpx_verify_setting()) as client:
213214
# Try /models endpoint (standard OpenAI-compatible)
214215
response = await client.get(
215216
target.url,
@@ -262,7 +263,7 @@ async def _test_anthropic_compatible_connection(
262263
if api_key:
263264
headers["x-api-key"] = api_key
264265

265-
async with httpx.AsyncClient(timeout=10.0) as client:
266+
async with httpx.AsyncClient(timeout=10.0, verify=httpx_verify_setting()) as client:
266267
response = await client.get(
267268
target.url,
268269
headers=headers,

open_notebook/utils/ssl_config.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""SSL settings shared by Esperanto providers and raw httpx clients.
2+
3+
Connection tests and model discovery use httpx directly. Those paths must
4+
honor the same ESPERANTO_SSL_* environment variables documented for Esperanto,
5+
otherwise corporate / self-signed HTTPS endpoints pass chat but fail
6+
"Test Connection" / Discover Models with a misleading ConnectError.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import os
12+
from typing import Union
13+
14+
# Keep names aligned with esperanto.utils.ssl
15+
SSL_VERIFY_ENV_VAR = "ESPERANTO_SSL_VERIFY"
16+
SSL_CA_BUNDLE_ENV_VAR = "ESPERANTO_SSL_CA_BUNDLE"
17+
18+
19+
def httpx_verify_setting() -> Union[bool, str]:
20+
"""Return a value suitable for ``httpx.AsyncClient(verify=...)``.
21+
22+
Priority (highest first), matching Esperanto's SSLMixin:
23+
24+
1. ``ESPERANTO_SSL_CA_BUNDLE`` — path to a CA bundle file
25+
2. ``ESPERANTO_SSL_VERIFY=false|0|no`` — disable verification
26+
3. Default — ``True`` (system trust store)
27+
28+
Raises:
29+
ValueError: If ``ESPERANTO_SSL_CA_BUNDLE`` is set but the file is
30+
missing. Esperanto rejects invalid bundles instead of falling
31+
through to ``ESPERANTO_SSL_VERIFY``; we do the same so Test
32+
Connection cannot silently disable TLS when a CA path is wrong.
33+
"""
34+
ca_bundle = os.getenv(SSL_CA_BUNDLE_ENV_VAR)
35+
if ca_bundle:
36+
if not os.path.isfile(ca_bundle):
37+
raise ValueError(f"CA bundle file not found: {ca_bundle}")
38+
return ca_bundle
39+
40+
verify_env = os.getenv(SSL_VERIFY_ENV_VAR, "").lower()
41+
if verify_env in ("false", "0", "no"):
42+
return False
43+
44+
return True

tests/test_credentials_api.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ async def test_openai_discovery_respects_base_url(self, monkeypatch):
110110
requests = []
111111

112112
class FakeAsyncClient:
113+
def __init__(self, *args, **kwargs):
114+
# Accept httpx.AsyncClient kwargs (e.g. verify=...)
115+
pass
116+
113117
async def __aenter__(self):
114118
return self
115119

@@ -169,6 +173,10 @@ async def test_model_discovery_base_url_can_include_models_path(self, monkeypatc
169173
requests = []
170174

171175
class FakeAsyncClient:
176+
def __init__(self, *args, **kwargs):
177+
# Accept httpx.AsyncClient kwargs (e.g. verify=...)
178+
pass
179+
172180
async def __aenter__(self):
173181
return self
174182

@@ -210,6 +218,10 @@ async def test_anthropic_compatible_discovery_normalizes_models_path(
210218
requests = []
211219

212220
class FakeAsyncClient:
221+
def __init__(self, *args, **kwargs):
222+
# Accept httpx.AsyncClient kwargs (e.g. verify=...)
223+
pass
224+
213225
async def __aenter__(self):
214226
return self
215227

@@ -256,6 +268,10 @@ async def test_openai_discovery_pins_user_supplied_base_url(self, monkeypatch):
256268
captured = {}
257269

258270
class FakeAsyncClient:
271+
def __init__(self, *args, **kwargs):
272+
# Accept httpx.AsyncClient kwargs (e.g. verify=...)
273+
pass
274+
259275
async def __aenter__(self):
260276
return self
261277

@@ -318,6 +334,10 @@ async def test_omlx_discovery_defaults_base_url_and_pins(self, monkeypatch):
318334
pinned_calls = []
319335

320336
class FakeAsyncClient:
337+
def __init__(self, *args, **kwargs):
338+
# Accept httpx.AsyncClient kwargs (e.g. verify=...)
339+
pass
340+
321341
async def __aenter__(self):
322342
return self
323343

@@ -356,6 +376,10 @@ async def test_omlx_discovery_sends_optional_api_key(self, monkeypatch):
356376
captured = {}
357377

358378
class FakeAsyncClient:
379+
def __init__(self, *args, **kwargs):
380+
# Accept httpx.AsyncClient kwargs (e.g. verify=...)
381+
pass
382+
359383
async def __aenter__(self):
360384
return self
361385

tests/test_ssl_config.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Tests for ESPERANTO_SSL_* → httpx verify mapping."""
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from open_notebook.utils.ssl_config import httpx_verify_setting
8+
9+
10+
@pytest.fixture(autouse=True)
11+
def _clear_ssl_env(monkeypatch: pytest.MonkeyPatch) -> None:
12+
monkeypatch.delenv("ESPERANTO_SSL_VERIFY", raising=False)
13+
monkeypatch.delenv("ESPERANTO_SSL_CA_BUNDLE", raising=False)
14+
15+
16+
def test_default_verify_enabled() -> None:
17+
assert httpx_verify_setting() is True
18+
19+
20+
@pytest.mark.parametrize("value", ["false", "0", "no", "FALSE", "No"])
21+
def test_verify_disabled_via_env(value: str, monkeypatch: pytest.MonkeyPatch) -> None:
22+
monkeypatch.setenv("ESPERANTO_SSL_VERIFY", value)
23+
assert httpx_verify_setting() is False
24+
25+
26+
def test_ca_bundle_takes_priority_over_verify_false(
27+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
28+
) -> None:
29+
ca = tmp_path / "ca.pem"
30+
ca.write_text("dummy-ca\n", encoding="utf-8")
31+
monkeypatch.setenv("ESPERANTO_SSL_CA_BUNDLE", str(ca))
32+
monkeypatch.setenv("ESPERANTO_SSL_VERIFY", "false")
33+
assert httpx_verify_setting() == str(ca)
34+
35+
36+
def test_missing_ca_bundle_raises_instead_of_falling_through(
37+
monkeypatch: pytest.MonkeyPatch,
38+
) -> None:
39+
"""Misconfigured CA must not silently honor ESPERANTO_SSL_VERIFY=false."""
40+
monkeypatch.setenv("ESPERANTO_SSL_CA_BUNDLE", "/no/such/ca.pem")
41+
monkeypatch.setenv("ESPERANTO_SSL_VERIFY", "false")
42+
with pytest.raises(ValueError, match="CA bundle file not found"):
43+
httpx_verify_setting()

0 commit comments

Comments
 (0)