Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
09afafb
fix(xai): resolve Grok Build context for OAuth
Julientalbot May 22, 2026
d33c99b
fix(security): validate Nous Portal inference_base_url against host a…
memosr May 17, 2026
e32d2ff
fix(security): wire Nous URL allowlist into refresh / mint persistenc…
teknium1 May 22, 2026
4f98863
infographic: PR #27612 Nous URL allowlist salvage
teknium1 May 22, 2026
cc8e5ec
refactor(gateway): migrate Discord adapter to bundled plugin (full Te…
kshitijk4poor May 12, 2026
7849a3d
fix(gateway,discord-plugin): _platform_status must respect is_connect…
teknium1 May 22, 2026
d11cbb1
infographic: PR #30591 Discord adapter → bundled plugin salvage (#30614)
teknium1 May 22, 2026
6c3fd97
perf(termux): fast-path cli version startup
adybag14-cyber May 22, 2026
a3beee4
perf(termux): speed up bare cli prompt startup
adybag14-cyber May 22, 2026
2233b8b
infographic: PR #30609 Termux cold-start salvage (#30618)
teknium1 May 22, 2026
2f320cb
fix(ci): supply-chain-audit uses two-dot diff, causing false positive…
ethernet8023 May 22, 2026
a84cec6
fix(minimax-oauth): refresh short-lived access tokens per request (#3…
teknium1 May 22, 2026
b689624
feat(ci): 4-way matrix slicing with LPT duration-balanced distribution
ethernet8023 May 21, 2026
510df6e
test: 4-way slice benchmark (with cache save)
ethernet8023 May 22, 2026
f89afdb
fix(test): deflake two intermittent CI failures
ethernet8023 May 22, 2026
e7cb5d4
fix: clean push triggers
ethernet8023 May 22, 2026
dc4b046
feat(ci): use 6-way slicing based on benchmark results
ethernet8023 May 22, 2026
487c398
refactor(web): dashboard typography & contrast pass
austinpickett May 23, 2026
8cf977c
fix(plugins): widen _sanitize_plugin_name for category-namespaced names
teknium1 May 23, 2026
b82608a
fix(skills,pairing): path traversal guard in uninstall, lock list_pen…
aaronlab Apr 9, 2026
3f78d80
fix(skills): make content_hash filename-sensitive too (symmetric with…
teknium1 May 23, 2026
7f7245b
infographic: PR #6656 skill hub safety audit salvage
teknium1 May 23, 2026
056e00a
fix(file-safety): block read_file on HERMES_HOME credential stores (#…
briandevans Apr 29, 2026
567ea61
fix(file-safety): block auth.json read via TERMINAL_CWD relative path
briandevans Apr 30, 2026
97e975e
fix(file-safety): widen read-deny to .env, mcp-tokens/, webhook secre…
teknium1 May 23, 2026
729a778
infographic: PR #17659 read-deny credentials salvage
teknium1 May 23, 2026
4a91e36
fix(gateway): separate observed Telegram group context
Glucksberg May 21, 2026
41d2c75
Fix unsafe gateway media path delivery
egilewski May 22, 2026
11e6dd3
chore(release): add AUTHOR_MAP entry for egilewski (PR #30432) (#30833)
teknium1 May 23, 2026
09f85f2
fix(plugins): apply truthy env semantics to project-plugin gate (#29156)
xxxigm May 20, 2026
da636e9
test(plugins): regression coverage for project-plugin RCE chain (#29156)
xxxigm May 20, 2026
8bf9922
fix(plugins): block plugin-api path traversal + project RCE (#29156)
xxxigm May 20, 2026
6a2df9f
docs(env): clarify HERMES_ENABLE_PROJECT_PLUGINS contract (#29156)
xxxigm May 20, 2026
3127a41
test(acp): pin parse_model_input in slash-command tests
teknium1 May 23, 2026
52a368f
fix(gateway): preserve WhatsApp pairing approvals across JID/LID alia…
QuenVix May 23, 2026
71291d8
test: keep tirith checks hermetic
helix4u May 22, 2026
3589960
fix(provider): expose OpenCode Go reasoning controls
counterposition May 23, 2026
70aaa77
fix(opencode-go): emit Kimi reasoning_effort, match KimiProfile shape
teknium1 May 23, 2026
b2e6fdd
fix(agent): log warning when fallback model normalization fails inste…
sprmn24 May 22, 2026
5772e63
chore: drop in-repo infographic/ directory; keep PR-body URLs only (#…
teknium1 May 23, 2026
99671a8
test(kanban): allow tmp_path artifacts past media-delivery validator
teknium1 May 23, 2026
d21ac57
fix(gateway): honor key_env in auth-failure fallback resolution
QuenVix May 23, 2026
2362cc4
fix(gateway): enforce env variable template expansion on runtime conf…
QuenVix May 23, 2026
66d81f9
fix(gateway): don't swallow expansion errors in runtime config helper
teknium1 May 23, 2026
bbd77d1
fix(qqbot): add INTERACTION intent and expose video/file cached paths
WideLee May 18, 2026
a54f5af
fix(qqbot): handle op 7/9 and expand fatal close code set
WideLee May 18, 2026
0e7448d
fix(qqbot): use original attachment filename for cached files
WideLee May 18, 2026
60b0a0e
fix(qqbot): fix SILK magic byte detection slice length
WideLee May 18, 2026
b183be9
fix(gateway-windows): atomic write for .cmd and startup launcher scripts
sprmn24 May 22, 2026
53cb6d3
fix(agent): use atomic_json_write for request debug dumps instead of …
sprmn24 May 22, 2026
e2a7d73
fix(force_close_tcp_sockets): shutdown only, do not release FD (#29507)
xxxigm May 21, 2026
30c22f1
fix(api-call): defer client.close() to owning worker thread on interr…
xxxigm May 21, 2026
5b6f0b6
test(tls-fd-recycle): pin shutdown-only + thread-aware close contract…
xxxigm May 21, 2026
db489a3
fix(tests): allowlist tmp_path for kanban_notify artifact delivery (#…
teknium1 May 23, 2026
0f8215f
fix(security): correct verdict logic and enforce --force limitation i…
sprmn24 Mar 26, 2026
789043b
fix(security): update tests for verdict and --force changes
sprmn24 Mar 26, 2026
6942b18
fix(skills_guard): explain why --force is rejected on dangerous verdicts
teknium1 May 23, 2026
b4cf5b6
feat(portal): one-shot setup, status CLI, and Nous-included markers (…
teknium1 May 23, 2026
61ac118
fix(webhook): enforce INSECURE_NO_AUTH safety rail on dynamic route r…
Zyrixtrex May 22, 2026
4b6d68b
test(fast-command): stub _load_gateway_runtime_config too
teknium1 May 23, 2026
9acf949
feat(telegram): edit status messages in place instead of appending (#…
teknium1 May 23, 2026
f3fb789
docs: surface 'hermes setup --portal' and 'hermes portal' across user…
teknium1 May 23, 2026
64b3eb0
docs: surface Nous Portal on pages where it solves a real problem the…
teknium1 May 23, 2026
8b3cb93
fix(xai-oauth): honor [WKE=unauthenticated:...] disambiguator in enti…
xxxigm May 20, 2026
b5ea6a5
test(xai-oauth): regression coverage for the bad-credentials disambig…
xxxigm May 20, 2026
cc93053
fix(xai-oauth): apply WKE disambiguator to recovery-path catch-all (#…
teknium1 May 23, 2026
6855d17
fix(memory): guard against external drift in MEMORY.md/USER.md (#2604…
teknium1 May 23, 2026
7f1b2b4
fix(approval): pin 'silence is not consent' contract on timeout/deny …
teknium1 May 23, 2026
7245bc7
fix(fallback): merge fallback_providers with legacy fallback_model co…
QuenVix May 23, 2026
e97a4c8
docs(readme): add Nous Portal section between Getting Started and CLI…
teknium1 May 23, 2026
39fe4ec
fix(kanban): refuse corrupt db auto-init
NickLarcombe May 23, 2026
4f835f7
chore(release): map NickLarcombe author email for #30707 salvage
teknium1 May 23, 2026
c4b8f5e
fix(kanban): harden corrupt-db backup against CodeQL path-injection f…
teknium1 May 23, 2026
cae7537
infographic: kanban.db corruption defense (#30858 + #30862) (#30952)
teknium1 May 23, 2026
ad11327
feat(kanban): warn users that scratch workspaces are deleted on compl…
teknium1 May 23, 2026
026f64f
fix(tui): commit composer input bursts immediately (#31053)
OutThisLife May 23, 2026
e6ca730
fix(tui): log parent gateway lifecycle exits (#31051)
OutThisLife May 23, 2026
874c2b1
fix(tui): ignore late thinking deltas after completion (#31055)
OutThisLife May 23, 2026
2789d52
fix(security): enable guard_agent_created by default
invalid-email-address May 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/supply-chain-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,17 @@ jobs:
HEAD="${{ github.event.pull_request.head.sha }}"

# Added lines only, excluding lockfiles.
DIFF=$(git diff "$BASE".."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
# Three-dot diff (base...head) diffs from the merge base to HEAD,
# so only changes introduced by this PR are included — not changes
# that landed on main after the PR branched off.
DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)

FINDINGS=""

# --- .pth files (auto-execute on Python startup) ---
# The exact mechanism used in the litellm supply chain attack:
# https://github.com/BerriAI/litellm/issues/24512
PTH_FILES=$(git diff --name-only "$BASE".."$HEAD" | grep '\.pth$' || true)
PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
if [ -n "$PTH_FILES" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: .pth file added or modified
Expand Down Expand Up @@ -97,7 +100,7 @@ jobs:

# --- Install-hook files (setup.py/sitecustomize/usercustomize/__init__.pth) ---
# These execute during pip install or interpreter startup.
SETUP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(^|/)(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '(^|/)(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
if [ -n "$SETUP_HITS" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: Install-hook file added or modified
Expand Down Expand Up @@ -158,7 +161,7 @@ jobs:
HEAD="${{ github.event.pull_request.head.sha }}"

# Only check added lines in pyproject.toml
ADDED=$(git diff "$BASE".."$HEAD" -- pyproject.toml | grep '^+' | grep -v '^+++' || true)
ADDED=$(git diff "$BASE"..."$HEAD" -- pyproject.toml | grep '^+' | grep -v '^+++' || true)

if [ -z "$ADDED" ]; then
echo "found=false" >> "$GITHUB_OUTPUT"
Expand Down
65 changes: 61 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,22 @@ concurrency:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
slice: [1, 2, 3, 4, 5, 6]
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Restore duration cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: test_durations.json
# Single stable key. main always overwrites, PRs always find it.
key: test-durations

- name: Install ripgrep (prebuilt binary)
run: |
set -euo pipefail
Expand All @@ -54,7 +65,7 @@ jobs:
source .venv/bin/activate
uv pip install -e ".[all,dev]"

- name: Run tests
- name: Run tests (slice ${{ matrix.slice }}/6)
# Per-file isolation via scripts/run_tests_parallel.py: discovers
# every test_*.py file under tests/ (excluding integration/ + e2e/),
# then runs `python -m pytest <file>` in a freshly-spawned subprocess
Expand All @@ -72,15 +83,61 @@ jobs:
# state across files, which is exactly the leakage we wanted to
# fix. ThreadPoolExecutor + subprocess.run is ~60 lines and does
# the job with cleaner semantics.
#
# Matrix slicing (--slice I/N): files are distributed across 6
# jobs by cached duration (LPT algorithm) so each job gets
# roughly equal wall time. Without a cache, files default to 2s
# estimate and get split roughly evenly by count — still correct,
# just not perfectly balanced.
run: |
source .venv/bin/activate
python scripts/run_tests_parallel.py
python scripts/run_tests_parallel.py --slice ${{ matrix.slice }}/6
env:
# Ensure tests don't accidentally call real APIs
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""

- name: Upload per-slice durations
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-durations-slice-${{ matrix.slice }}
path: test_durations.json
retention-days: 1

# Merge per-slice duration data into a single cache, so future runs
# (including PRs) get balanced slicing.
save-durations:
needs: test
if: always() && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Download all slice durations
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: test-durations-slice-*
path: durations
merge-multiple: true

- name: Merge into single durations file
run: |
python3 -c "
import json, glob, os
merged = {}
for f in glob.glob('durations/*test_durations.json'):
with open(f) as fh:
merged.update(json.load(fh))
with open('test_durations.json', 'w') as fh:
json.dump(merged, fh, indent=2, sort_keys=True)
print(f'Merged {len(merged)} file durations')
"

- name: Save merged duration cache
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: test_durations.json
key: test-durations

e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
Expand Down Expand Up @@ -121,4 +178,4 @@ jobs:
env:
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
NOUS_API_KEY: ""
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ __pycache__/web_tools.cpython-310.pyc
logs/
data/
.pytest_cache/
test_durations.json
.pytest-cache/
tmp/
temp_vision_images/
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,27 @@ hermes doctor # Diagnose any issues

📖 **[Full documentation →](https://hermes-agent.nousresearch.com/docs/)**

---

## Skip the API-key collection — Nous Portal

Hermes works with whatever provider you want — that's not changing. But if you'd rather not collect five separate API keys for the model, web search, image generation, TTS, and a cloud browser, **[Nous Portal](https://portal.nousresearch.com)** covers all of them under one subscription:

- **300+ models** — pick any of them with `/model <name>`
- **Tool Gateway** — web search (Firecrawl), image generation (FAL), text-to-speech (OpenAI), cloud browser (Browser Use), all routed through your sub. No extra accounts.

One command from a fresh install:

```bash
hermes setup --portal
```

That logs you in via OAuth, sets Nous as your provider, and turns on the Tool Gateway. Check what's wired up any time with `hermes portal status`. Full details on the [Tool Gateway docs page](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway).

You can still bring your own keys per-tool whenever you want — the gateway is per-backend, not all-or-nothing.

---

## CLI vs Messaging Quick Reference

Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces.
Expand Down
21 changes: 21 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,27 @@ hermes doctor # 诊断问题

📖 **[完整文档 →](https://hermes-agent.nousresearch.com/docs/)**

---

## 省去到处收集 API Key — Nous Portal

Hermes 始终允许你使用任意服务商,这点不会改变。但如果你不想为模型、网页搜索、图像生成、TTS、云浏览器分别去申请五个不同的 API Key,**[Nous Portal](https://portal.nousresearch.com)** 用一个订阅就能覆盖全部:

- **300+ 模型** — 用 `/model <name>` 随时切换
- **Tool Gateway** — 网页搜索(Firecrawl)、图像生成(FAL)、文本转语音(OpenAI)、云浏览器(Browser Use),全部通过订阅托管。无需额外注册任何账户。

全新安装时一条命令即可:

```bash
hermes setup --portal
```

它会通过 OAuth 登录、把 Nous 设为推理服务商,并启用 Tool Gateway。随时用 `hermes portal status` 查看路由状态。完整说明见 [Tool Gateway 文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway)。

你随时可以按工具单独切回自己的 API Key — Gateway 是按工具粒度生效的,不是一刀切。

---

## CLI 与消息平台 快速对照

Hermes 有两种入口:用 `hermes` 启动终端 UI,或运行网关从 Telegram、Discord、Slack、WhatsApp、Signal 或 Email 与之对话。进入对话后,许多斜杠命令在两种界面中通用。
Expand Down
27 changes: 26 additions & 1 deletion agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,31 @@ def init_agent(
# Falling back would send Anthropic credentials to third-party endpoints (Fixes #1739, #minimax-401).
_is_native_anthropic = agent.provider == "anthropic"
effective_key = (api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or "")

# MiniMax OAuth issues short-lived (~15-min) access tokens. The
# Anthropic SDK caches ``api_key`` as a static string at client
# construction time, so a session that resolves the bearer once
# at startup will keep sending the same token until MiniMax
# returns 401 mid-session. Swap the static string for a callable
# token provider — ``build_anthropic_client`` recognizes the
# callable and installs an httpx event hook that mints a fresh
# bearer per outbound request (re-reading auth.json so a refresh
# persisted by another process is visible immediately).
# The cached refresh path is a no-op when the token still has
# ``MINIMAX_OAUTH_REFRESH_SKEW_SECONDS`` of life left, so steady-
# state cost is one file read + one timestamp compare per request.
if agent.provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key:
try:
from hermes_cli.auth import build_minimax_oauth_token_provider
effective_key = build_minimax_oauth_token_provider()
except Exception as _mm_exc: # noqa: BLE001 — never block startup on this
import logging as _logging
_logging.getLogger(__name__).warning(
"MiniMax OAuth: failed to install per-request token provider "
"(%s); falling back to static bearer that will expire ~15min in.",
_mm_exc,
)

agent.api_key = effective_key
agent._anthropic_api_key = effective_key
agent._anthropic_base_url = base_url
Expand All @@ -618,7 +643,7 @@ def init_agent(
# that cause 401/403 on their endpoints. Guards #1739 and
# the third-party identity-injection bug.
from agent.anthropic_adapter import _is_oauth_token as _is_oat
agent._is_anthropic_oauth = _is_oat(effective_key) if _is_native_anthropic else False
agent._is_anthropic_oauth = _is_oat(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
agent._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout)
# No OpenAI client needed for Anthropic mode
agent.client = None
Expand Down
99 changes: 77 additions & 22 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,9 +617,28 @@ def recover_with_credential_pool(
# existing entitlement keyword set in ``_is_entitlement_failure``.
# Any 403 against ``xai-oauth`` is treated as entitlement here so
# the refresh loop can't spin in those cases either.
#
# Exception (#29344): xAI's ``[WKE=unauthenticated:...]`` suffix and
# the ``OAuth2 access token could not be validated`` phrasing are
# xAI's authoritative "this is a stale token, not entitlement"
# signal. When either fires we must NOT apply the catch-all
# override — refresh is the recoverable path for these bodies, and
# blanket-classifying them as entitlement was the bug that left
# long-running TUI sessions stuck on stale tokens until the user
# exited and reopened.
is_entitlement = agent._is_entitlement_failure(error_context, status_code)
if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth":
is_entitlement = True
_disambiguator_haystack = " ".join(
str(error_context.get(k) or "").lower()
for k in ("message", "reason", "code", "error")
if isinstance(error_context, dict)
)
_is_xai_auth_failure = (
"[wke=unauthenticated:" in _disambiguator_haystack
or "oauth2 access token could not be validated" in _disambiguator_haystack
)
if not _is_xai_auth_failure:
is_entitlement = True
if is_entitlement:
_ra().logger.info(
"Credential %s — entitlement-shaped 403 from %s; "
Expand Down Expand Up @@ -1064,10 +1083,7 @@ def dump_api_request_debug(

timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json"
dump_file.write_text(
json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
atomic_json_write(dump_file, dump_payload, default=str)

agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}")

Expand Down Expand Up @@ -1352,14 +1368,30 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# API key — falling back would send Anthropic credentials to third-party endpoints.
_is_native_anthropic = new_provider == "anthropic"
effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "")

# MiniMax OAuth: swap static string for a per-request callable token
# provider so the rebuilt client survives 15-min token expiry. See
# the matching block in agent_init.py for the full rationale.
if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key:
try:
from hermes_cli.auth import build_minimax_oauth_token_provider
effective_key = build_minimax_oauth_token_provider()
except Exception as _mm_exc: # noqa: BLE001
import logging as _logging
_logging.getLogger(__name__).warning(
"MiniMax OAuth: failed to install per-request token provider "
"on switch (%s); using static bearer.",
_mm_exc,
)

agent.api_key = effective_key
agent._anthropic_api_key = effective_key
agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None)
agent._anthropic_client = build_anthropic_client(
effective_key, agent._anthropic_base_url,
timeout=get_provider_request_timeout(agent.provider, agent.model),
)
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if _is_native_anthropic else False
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
agent.client = None
agent._client_kwargs = {}
else:
Expand Down Expand Up @@ -2116,33 +2148,56 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in


def force_close_tcp_sockets(client: Any) -> int:
"""Force-close underlying TCP sockets to prevent CLOSE-WAIT accumulation.

When a provider drops a connection mid-stream, httpx's ``client.close()``
performs a graceful shutdown which leaves sockets in CLOSE-WAIT until the
OS times them out (often minutes). This method walks the httpx transport
pool and issues ``socket.shutdown(SHUT_RDWR)`` + ``socket.close()`` to
force an immediate TCP RST, freeing the file descriptors.

Returns the number of sockets force-closed.
"""Abort in-flight TCP I/O by shutting down sockets WITHOUT closing FDs.

When a provider drops a connection mid-stream — or the user issues an
interrupt — we want to unblock httpx's reader/writer immediately rather
than waiting for the kernel's per-connection timeout. ``shutdown(SHUT_RDWR)``
achieves that: it sends FIN, breaks any pending ``recv``/``send`` with EOF
or ``EPIPE``, but does NOT release the file descriptor.

Historically this helper also called ``socket.close()`` so the FD got
released immediately, but that's unsafe when (as is the case for both the
interrupt-abort path and stale-call kill path) the helper runs on a
different thread than the one driving the request:

* The Python ``socket.socket`` we close here is the SAME object held by
httpx's pool, so closing it via Python sets its ``_fd`` to -1 and
future operations on that Python object fail safely.
* BUT the SSL wrapper (``ssl.SSLSocket``'s underlying OpenSSL ``BIO``)
caches the raw integer FD. Once ``os.close(fd)`` runs, the kernel may
immediately recycle that integer to the next ``open()`` call — e.g.
the kanban dispatcher opening ``kanban.db``.
* The owning worker thread then unwinds httpx, the SSL layer flushes a
pending TLS record, and the encrypted bytes get written into the
wrong file (issue #29507: 24-byte TLS application-data record
clobbering SQLite header bytes 5..28).

The fix is to let the owning thread own the close. ``shutdown()`` from any
thread is FD-safe; ``close()`` is not. The httpx connection's own close
path — which runs from the worker thread when it unwinds — will release
the FD via the same ``socket.socket`` object, and because Python's socket
close atomically swaps ``_fd`` to -1 *before* issuing ``os.close``, there
is no FD-aliasing window when only one thread closes.

Returns the number of sockets shut down. (Field kept as
``tcp_force_closed=N`` in the log line for backwards-compatible parsing.)
"""
import socket as _socket

closed = 0
shutdown_count = 0
try:
for sock in _iter_pool_sockets(client):
try:
sock.shutdown(_socket.SHUT_RDWR)
except OSError:
# Already shut down / not connected / FD invalid — all benign.
pass
try:
sock.close()
except OSError:
pass
closed += 1
# IMPORTANT (#29507): do NOT call sock.close() here. See docstring.
shutdown_count += 1
except Exception as exc:
_ra().logger.debug("Force-close TCP sockets sweep error: %s", exc)
return closed
return shutdown_count



Expand Down
Loading
Loading