fix(cli): detect ssl.SSLError by type in the Codex login hint; trim tests; add the openssl.cnf snippet to docs

- _ssl_interop_hint: also match ssl.SSLError instances (and one level of
  __cause__/__context__) plus the bare UNEXPECTED_EOF marker, so an
  SSLEOFError whose text httpx did not repeat still gets the hint. The
  hint now names the TLS 1.2 diagnostic and links the providers docs
  note instead of an issue number.
- tests: 3 -> 2 invariants (parametrized login_post/poll SSL case keeps
  the raw text + hint + cause; a plain httpx timeout gets no hint).
- docs: providers.md Codex note carries the reporter's exact openssl.cnf
  classic-groups snippet (EN + existing zh-Hans copy).

Refs #106384. The TLS max-version cap itself stays PR #44392's scope.
This commit is contained in:
teknium1
2026-09-09 04:41:28 -07:00
committed by Teknium
parent ef903617ea
commit d3dcc064df
4 changed files with 75 additions and 47 deletions

View File

@@ -212,7 +212,7 @@ def _refresh_payload_access_token(
return payload, access
_SSL_TROUBLE_MARKERS = ("[SSL:", "_ssl.c")
_SSL_TROUBLE_MARKERS = ("[SSL:", "_ssl.c", "UNEXPECTED_EOF")
def _ssl_interop_hint(exc: BaseException) -> str:
@@ -221,14 +221,23 @@ def _ssl_interop_hint(exc: BaseException) -> str:
OpenSSL 3.5+ advertises post-quantum hybrid groups (e.g. X25519MLKEM768) by default, and some
intercepting middleboxes reject the resulting larger TLS 1.3 ClientHello — while curl, using a
different TLS stack, still works, so the failure masquerades as a Codex outage (#106384).
httpx wraps the ``ssl.SSLError`` in a ``ConnectError``/``ConnectTimeout`` whose text usually
repeats the OpenSSL message; the cause chain is checked too in case it doesn't.
"""
if not any(marker in str(exc) for marker in _SSL_TROUBLE_MARKERS):
import ssl
chain = (exc, exc.__cause__, exc.__context__)
if not any(
isinstance(err, ssl.SSLError) or any(marker in str(err) for marker in _SSL_TROUBLE_MARKERS)
for err in chain if err is not None
):
return ""
return (
" This looks like a TLS handshake failure rather than a Codex outage: some networks reject"
" the larger TLS 1.3 ClientHello that OpenSSL 3.5+ sends by default (post-quantum hybrid"
" groups). As a workaround, point OPENSSL_CONF at a config restricting Groups to classic"
" curves (x25519:secp256r1:secp384r1:x448) — see #106384 for details."
" groups). Workaround: point OPENSSL_CONF at a config restricting Groups to classic curves"
" (x25519:secp256r1:secp384r1:x448), or test with TLS 1.2 — see the Codex note in"
" https://hermes-agent.nousresearch.com/docs/integrations/providers"
)

View File

@@ -1,16 +1,14 @@
"""Regression tests: Codex device-login transport errors keep the underlying SSL detail.
"""Codex device-login transport errors keep the underlying SSL detail and add a hint (#106384).
On networks whose middlebox rejects the larger TLS 1.3 ClientHello that OpenSSL 3.5+ sends
(post-quantum hybrid groups), every device-login HTTP call dies with ``SSLEOFError`` /
handshake timeouts while curl still works (#106384). Previously:
* ``_codex_login_post`` swallowed the exception chain (no ``from exc``) and gave no hint;
* the polling loop let the raw ``httpx`` error escape with no ``AuthError`` shaping at all.
Both paths must keep the original SSL text, chain the cause, and append the OPENSSL_CONF
workaround hint so the failure stops masquerading as a Codex outage.
OpenSSL 3.5+ advertises post-quantum hybrid groups; some middleboxes drop the resulting larger
TLS 1.3 ClientHello, so every device-login POST dies with ``SSLEOFError`` / handshake timeouts
while curl still works. Both transport paths (``_codex_login_post`` and the poll loop, which
previously let the raw ``httpx`` error escape unshaped) must surface a typed ``AuthError`` that
keeps the original text and appends the OPENSSL_CONF / TLS 1.2 hint — and only for SSL errors.
"""
import ssl
import httpx
import pytest
@@ -38,48 +36,41 @@ class _RaisingClient:
raise self._exc
def _patch_client(monkeypatch, exc: BaseException) -> None:
monkeypatch.setattr(auth_codex, "_codex_http_client", lambda **kwargs: _RaisingClient(exc))
def _login_post():
return auth_codex._codex_login_post(
"https://auth.openai.com/api/accounts/deviceauth/usercode",
failure=("Failed to request device code", "device_code_request_failed"))
def test_login_post_ssl_error_keeps_detail_and_hint(monkeypatch):
_patch_client(monkeypatch, httpx.ConnectError(_SSL_EOF_MESSAGE))
def _poll():
return auth_codex._codex_poll_authorization_code(
"https://auth.openai.com", device_auth_id="da", user_code="uc", poll_interval=0)
@pytest.mark.parametrize(
"call, code",
[(_login_post, "device_code_request_failed"), (_poll, "device_code_poll_error")],
ids=["login_post", "poll"])
def test_ssl_transport_error_keeps_detail_and_adds_hint(monkeypatch, call, code):
exc = ssl.SSLEOFError(8, _SSL_EOF_MESSAGE)
monkeypatch.setattr(auth_codex, "_codex_http_client", lambda **kw: _RaisingClient(exc))
with pytest.raises(AuthError) as excinfo:
auth_codex._codex_login_post(
"https://auth.openai.com/api/accounts/deviceauth/usercode",
failure=("Failed to request device code", "device_code_request_failed"))
call()
err = excinfo.value
assert err.code == "device_code_request_failed"
assert _SSL_EOF_MESSAGE in str(err)
assert err.code == code
assert "UNEXPECTED_EOF_WHILE_READING" in str(err)
assert "OPENSSL_CONF" in str(err)
assert "#106384" in str(err)
# ``raise ... from exc`` keeps the underlying transport error inspectable.
assert isinstance(err.__cause__, httpx.ConnectError)
assert err.__cause__ is exc
def test_login_post_non_ssl_error_has_no_interop_hint(monkeypatch):
_patch_client(monkeypatch, httpx.ConnectError("Connection refused"))
def test_plain_timeout_has_no_ssl_hint(monkeypatch):
exc = httpx.ConnectTimeout("timed out")
monkeypatch.setattr(auth_codex, "_codex_http_client", lambda **kw: _RaisingClient(exc))
with pytest.raises(AuthError) as excinfo:
auth_codex._codex_login_post(
"https://auth.openai.com/api/accounts/deviceauth/usercode",
failure=("Failed to request device code", "device_code_request_failed"))
_login_post()
assert "Connection refused" in str(excinfo.value)
assert "timed out" in str(excinfo.value)
assert "OPENSSL_CONF" not in str(excinfo.value)
def test_poll_authorization_code_ssl_error_surfaced_as_auth_error(monkeypatch):
_patch_client(monkeypatch, httpx.ConnectError(_SSL_EOF_MESSAGE))
with pytest.raises(AuthError) as excinfo:
auth_codex._codex_poll_authorization_code(
"https://auth.openai.com", device_auth_id="da", user_code="uc", poll_interval=0)
err = excinfo.value
assert err.code == "device_code_poll_error"
assert _SSL_EOF_MESSAGE in str(err)
assert "OPENSSL_CONF" in str(err)
assert isinstance(err.__cause__, httpx.ConnectError)

View File

@@ -94,7 +94,20 @@ The OpenAI Codex provider authenticates via device code (open a URL, enter a cod
If a token refresh fails with a terminal error (HTTP 4xx, `invalid_grant`, revoked grant, etc.), Hermes marks the refresh token as dead and stops replaying it so you don't see a flood of identical auth failures. The next request surfaces a typed re-auth message instead. Run `hermes auth add openai-codex` (or `hermes model` → **ChatGPT or Codex Subscription**) to start a fresh device-code login; the quarantine clears on the next successful exchange.
Device login can fail with `[SSL: UNEXPECTED_EOF_WHILE_READING]` or a TLS handshake timeout on Python/OpenSSL 3.5+ when a middlebox rejects post-quantum groups such as X25519MLKEM768 (curl may still work). Hermes does not change default TLS policy. Point `OPENSSL_CONF` at a config that restricts `Groups` to classic curves (`x25519:secp256r1:secp384r1:x448`), or diagnose with TLS 1.2.
Device login can fail with `[SSL: UNEXPECTED_EOF_WHILE_READING]` or a TLS handshake timeout on Python/OpenSSL 3.5+ when a middlebox rejects post-quantum groups such as X25519MLKEM768 (curl may still work). Hermes does not change default TLS policy. Point `OPENSSL_CONF` at a config that restricts `Groups` to classic curves before running `hermes model`, or diagnose with TLS 1.2:
```ini
openssl_conf = openssl_init
[openssl_init]
ssl_conf = ssl_sect
[ssl_sect]
system_default = system_default_sect
[system_default_sect]
Groups = x25519:secp256r1:secp384r1:x448
```
:::
:::warning

View File

@@ -70,6 +70,21 @@ hermes portal info # 随时查看登录状态和路由信息
OpenAI Codex 提供商通过设备码(device code)认证——打开一个 URL 并输入验证码。Hermes 将生成的凭据存储在 `~/.hermes/auth.json` 的自有认证存储中,并在存在 `~/.codex/auth.json` 时可导入现有的 Codex CLI 凭据。无需安装 Codex CLI。
如果 token 刷新因终端错误(HTTP 4xx、`invalid_grant`、授权被撤销等)失败,Hermes 会将该刷新 token 标记为失效并停止重试,避免出现大量重复的认证失败。下一次请求会显示类型化的重新认证提示。运行 `hermes auth add openai-codex`(或 `hermes model` → **ChatGPT or Codex Subscription**)开始新的设备码登录;成功交换后隔离状态自动解除。
在 Python/OpenSSL 3.5+ 上,如果网络中间设备拒绝 X25519MLKEM768 等后量子密钥交换组,设备码登录可能报 `[SSL: UNEXPECTED_EOF_WHILE_READING]` 或 TLS 握手超时(此时 curl 仍可能正常)。Hermes 不会修改默认 TLS 策略。请在运行 `hermes model` 前将 `OPENSSL_CONF` 指向一个把 `Groups` 限制为经典曲线的配置文件,或用 TLS 1.2 进行诊断:
```ini
openssl_conf = openssl_init
[openssl_init]
ssl_conf = ssl_sect
[ssl_sect]
system_default = system_default_sect
[system_default_sect]
Groups = x25519:secp256r1:secp384r1:x448
```
:::
:::warning