From d3dcc064dfdb09a6f74d796d5871b1942c0638f3 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:41:28 -0700 Subject: [PATCH] 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. --- hermes_cli/auth_codex.py | 17 ++++- .../test_codex_device_login_ssl_hint.py | 75 ++++++++----------- website/docs/integrations/providers.md | 15 +++- .../current/integrations/providers.md | 15 ++++ 4 files changed, 75 insertions(+), 47 deletions(-) diff --git a/hermes_cli/auth_codex.py b/hermes_cli/auth_codex.py index ca3daa6b16..fa0e6f8a21 100644 --- a/hermes_cli/auth_codex.py +++ b/hermes_cli/auth_codex.py @@ -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" ) diff --git a/tests/hermes_cli/test_codex_device_login_ssl_hint.py b/tests/hermes_cli/test_codex_device_login_ssl_hint.py index 5ba72cc17b..6a0d753b54 100644 --- a/tests/hermes_cli/test_codex_device_login_ssl_hint.py +++ b/tests/hermes_cli/test_codex_device_login_ssl_hint.py @@ -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) diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 362f5f21fd..1038f33bae 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -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 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md index d999056279..4a0513f519 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md @@ -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