From bcafbd69655ff232f7d9d24267c1e32f9da243ac Mon Sep 17 00:00:00 2001 From: laitekin <2625707287@qq.com> Date: Tue, 25 Aug 2026 16:19:11 +0800 Subject: [PATCH 01/47] fix(run-journal): skip metering telemetry on write and replay (#7272) Metering telemetry is journaled at ~10 Hz during active turns, so marathon reasoning runs balloon the durable run journal (12.4 MB of a single 18 MB run was metering). On a mid-run refresh the reconnect path replays the entire journal as one unpaced SSE burst, OOM-crashing the browser tab in a crash-refresh loop while the server stays healthy. - Write time: RunJournalWriter.append_sse_event() skips metering rows via REPLAY_SKIPPED_SSE_EVENTS, returning None so caller journal-id plumbing is untouched and remaining journaled seqs stay contiguous. - Replay time: _replay_run_journal() and emit_replay() filter metering rows through journal_replay_visible(), so legacy journals never stream the backlog either. - Readers (read_run_events / read_session_run_events) deliberately keep every row: cursor math and the offline-gap coverage check count seqs. Refs #7272 --- api/routes.py | 5 ++ api/run_journal.py | 33 +++++++++++++ tests/test_issue4812_session_sse_stream.py | 56 ++++++++++++++++++++++ tests/test_run_journal.py | 55 +++++++++++++++++++++ tests/test_run_journal_routes.py | 36 ++++++++++++++ 5 files changed, 185 insertions(+) diff --git a/api/routes.py b/api/routes.py index 7bfa4347..062a3772 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10710,6 +10710,7 @@ from api.run_journal import ( _parse_run_journal_event_id as _shared_parse_run_journal_event_id, bound_run_journal_snapshot_args, find_run_summary, + journal_replay_visible, read_run_events, read_session_run_events, session_journal_fingerprint, @@ -18575,6 +18576,8 @@ def _replay_run_journal( max_seq=max_seq, ) for entry in journal.get("events") or []: + if not journal_replay_visible(entry): + continue _sse_with_id( handler, entry.get("event") or entry.get("type") or "message", @@ -19110,6 +19113,8 @@ def _handle_session_run_journal_stream_for_session(handler, parsed, session_id): def emit_replay(events, stream_id, cutoff_seq): for entry in events: + if not journal_replay_visible(entry): + continue event_id = str(entry.get("event_id") or "") event_seq = _run_journal_same_run_seq(event_id, stream_id) if cutoff_seq is not None and event_seq is not None and event_seq > cutoff_seq: diff --git a/api/run_journal.py b/api/run_journal.py index ab8d0a7e..677876eb 100644 --- a/api/run_journal.py +++ b/api/run_journal.py @@ -45,6 +45,14 @@ TERMINAL_SSE_EVENTS = frozenset({"done", "cancel", "apperror", "error", "stream_ SSE_RELAY_CLOSE_EVENTS = frozenset({"stream_end", "cancel", "apperror", "error"}) # Back-compat alias used by older call sites / tests. _TERMINAL_SSE_EVENTS = TERMINAL_SSE_EVENTS +# Events that are live-UI-only telemetry with no recovery value in the run +# journal. They are skipped at WRITE time (never durably journaled, so they +# cannot bloat the journal on marathon runs) and filtered at REPLAY time (so +# legacy journals that already contain a backlog never stream it to a +# reconnecting browser tab). Readers deliberately do NOT filter: cursor math +# (``cursor_event_missing`` bound) and the offline-gap coverage check count +# journal seqs and must keep seeing every row. +REPLAY_SKIPPED_SSE_EVENTS = frozenset({"metering"}) _FSYNC_MODE_ENV = "HERMES_WEBUI_RUN_JOURNAL_FSYNC" _FSYNC_MODE_EAGER = "eager" _FSYNC_MODE_TERMINAL_ONLY = "terminal-only" @@ -447,6 +455,15 @@ class RunJournalWriter: self._lock = _lock_for(self._path) def append_sse_event(self, event_name: str, payload=None) -> dict: + # Live-UI-only telemetry (metering) has no recovery value in the journal: + # nothing reads those rows back for recovery, and journaling them at ~10 Hz + # on marathon runs balloons the durable file (12+ MB of a single 18 MB run + # was metering). Skip the write entirely and return None so callers' + # journal-id plumbing (``(journaled or {}).get("event_id")``) is untouched. + # Not reserving a seq keeps the remaining journaled seqs contiguous, which + # the offline-gap coverage and replay-cursor contiguity checks rely on. + if str(event_name or "").strip() in REPLAY_SKIPPED_SSE_EVENTS: + return None # Draw from the shared module-level seq cache under the per-path lock so # this writer and any direct append_run_event() call on the same path # agree on one monotonic, gapless sequence. @@ -462,6 +479,22 @@ class RunJournalWriter: ) +def journal_replay_visible(event) -> bool: + """Return True when a journal row should be streamed to a reconnecting tab. + + Live-UI-only telemetry rows (see ``REPLAY_SKIPPED_SSE_EVENTS``) carry no + recovery value — replaying a metering backlog only re-paints a stale TPS + number while multiplying the reconnect burst size. Writers no longer journal + them, but legacy journals may already contain them, so the replay emit sites + filter through this predicate. Non-dict rows are passed through (visible) so + an unexpected shape can never silently swallow user-visible output. + """ + if not isinstance(event, dict): + return True + name = str(event.get("event") or event.get("type") or "") + return name not in REPLAY_SKIPPED_SSE_EVENTS + + def read_run_events( session_id: str, run_id: str, diff --git a/tests/test_issue4812_session_sse_stream.py b/tests/test_issue4812_session_sse_stream.py index 68970c4e..dc3c2c3f 100644 --- a/tests/test_issue4812_session_sse_stream.py +++ b/tests/test_issue4812_session_sse_stream.py @@ -583,6 +583,62 @@ def test_session_route_live_delivery_skips_replayed_active_run_items(monkeypatch assert stream.unsubscribed is True +def test_session_route_replay_skips_metering_rows(monkeypatch): + import api.routes as routes + + class _FakeStream: + def __init__(self): + self.q = queue.Queue() + self.q.put_nowait(("stream_end", {"status": "done"}, "run_active:2")) + self.unsubscribed = False + + def subscribe_with_snapshot(self): + return self.q, {"last_event_id": "run_active:1", "offline_buffered_events": 1} + + def unsubscribe(self, q): + self.unsubscribed = q is self.q + + stream = _FakeStream() + monkeypatch.setattr(routes, "_session_id_visible_to_request_profile", lambda *_args, **_kwargs: True) + monkeypatch.setattr( + routes, + "get_session", + lambda sid, metadata_only=False: SimpleNamespace( + session_id=sid, + compact=lambda **_kwargs: {"session_id": sid, "title": "Session"}, + ), + ) + monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args, **_kwargs: "run_active") + monkeypatch.setattr(routes, "STREAMS", {"run_active": stream}) + # Both the pre-attach replay and the post-attach reconciliation draw from the + # same journal; a metering row among them must never reach the SSE body. + monkeypatch.setattr( + routes, + "read_session_run_events", + lambda *_args, **_kwargs: { + "status": "ok", + "events": [ + {"run_id": "run_prev", "seq": 1, "event": "token", "payload": {"text": "replayed"}, "event_id": "run_prev:1"}, + {"run_id": "run_prev", "seq": 2, "event": "metering", "payload": {"tps": 47.3}, "event_id": "run_prev:2"}, + ], + }, + ) + + handler = _FakeHandler() + routes._handle_session_sse_stream_for_session( + handler, + urlparse("/api/sessions/session_1/events?after_event_id=run_prev:0"), + "session_1", + ) + + body = handler.wfile.getvalue().decode("utf-8") + assert "id: run_prev:1\n" in body + assert "event: token\n" in body + assert "event: metering\n" not in body + assert "id: run_prev:2\n" not in body + assert stream.unsubscribed is True + + def test_session_route_breaks_on_terminal_even_when_reconciliation_replayed_it(monkeypatch): """CORE regression (#5677 gate r3): if reconciliation replays the active run's TERMINAL event at the subscribe cutoff, the live queue's copy is deduped — but diff --git a/tests/test_run_journal.py b/tests/test_run_journal.py index cce7186f..49133434 100644 --- a/tests/test_run_journal.py +++ b/tests/test_run_journal.py @@ -2,11 +2,14 @@ import json from pathlib import Path from api.run_journal import ( + REPLAY_SKIPPED_SSE_EVENTS, RunJournalWriter, append_run_event, find_run_summary, + journal_replay_visible, latest_run_summary, read_run_events, + read_session_run_events, stale_interrupted_event, ) @@ -28,6 +31,58 @@ def test_run_journal_appends_monotonic_seq_and_reads_after_cursor(tmp_path): assert [event["event"] for event in journal["events"]] == ["done"] +def test_writer_skips_metering_and_keeps_remaining_seqs_contiguous(tmp_path): + writer = RunJournalWriter("session_1", "run_1", session_dir=tmp_path) + + first = writer.append_sse_event("token", {"text": "hello"}) + skipped = writer.append_sse_event("metering", {"tps": 47.3}) + second = writer.append_sse_event("done", {"session": {}}) + + assert skipped is None + assert first["seq"] == 1 + # Skipped metering must NOT consume a seq, so visible rows stay contiguous — + # the offline-gap coverage and replay-cursor contiguity checks depend on it. + assert second["seq"] == 2 + + journal = read_run_events("session_1", "run_1", session_dir=tmp_path) + assert [event["event"] for event in journal["events"]] == ["token", "done"] + + +def test_writer_skip_is_payload_independent(tmp_path): + writer = RunJournalWriter("session_1", "run_1", session_dir=tmp_path) + + assert writer.append_sse_event("metering", None) is None + assert writer.append_sse_event("metering", {"tps": 1.0, "active": 1}) is None + + journal = read_run_events("session_1", "run_1", session_dir=tmp_path) + assert journal["events"] == [] + + +def test_journal_replay_visible_predicate(): + assert REPLAY_SKIPPED_SSE_EVENTS == {"metering"} + assert journal_replay_visible({"event": "token", "payload": {"text": "x"}}) is True + assert journal_replay_visible({"event": "metering", "payload": {"tps": 1.0}}) is False + # `type` fallback: metering rows are invisible under either field. + assert journal_replay_visible({"type": "metering"}) is False + # Unknown shapes must pass through (visible) so they can never swallow output. + assert journal_replay_visible("not-a-dict") is True + + +def test_readers_keep_metering_rows(tmp_path): + # Writers skip metering, but readers deliberately do NOT filter: cursor math + # (the ``cursor_event_missing`` bound) and the offline-gap coverage check + # count journal seqs and must keep seeing every row. + append_run_event("session_1", "run_1", "token", {"text": "hello"}, session_dir=tmp_path) + append_run_event("session_1", "run_1", "metering", {"tps": 47.3}, session_dir=tmp_path) + + journal = read_run_events("session_1", "run_1", session_dir=tmp_path) + assert [event["event"] for event in journal["events"]] == ["token", "metering"] + + replay = read_session_run_events("session_1", after_event_id="run_1:1", session_dir=tmp_path) + assert replay["status"] == "ok" + assert [event["event"] for event in replay["events"]] == ["metering"] + + def test_run_journal_reads_bounded_replay_window(tmp_path): writer = RunJournalWriter("session_1", "run_1", session_dir=tmp_path) diff --git a/tests/test_run_journal_routes.py b/tests/test_run_journal_routes.py index a0bd82d3..95077d2d 100644 --- a/tests/test_run_journal_routes.py +++ b/tests/test_run_journal_routes.py @@ -761,6 +761,42 @@ def test_replay_run_journal_honors_after_seq_cursor(monkeypatch): assert "event: done\n" in body +def test_replay_run_journal_skips_metering_rows(monkeypatch): + import api.routes as routes + + handler = SimpleNamespace(wfile=io.BytesIO()) + monkeypatch.setattr( + routes, + "find_run_summary", + lambda stream_id: { + "session_id": "session_1", + "run_id": stream_id, + "terminal": True, + }, + ) + monkeypatch.setattr( + routes, + "read_run_events", + lambda session_id, run_id, after_seq=None, max_seq=None: { + "events": [ + {"event": "token", "payload": {"text": "hello"}, "event_id": f"{run_id}:1"}, + {"event": "metering", "payload": {"tps": 47.3}, "event_id": f"{run_id}:2"}, + {"event": "done", "payload": {"session": {}}, "event_id": f"{run_id}:3"}, + ] + }, + ) + + assert routes._replay_run_journal(handler, "run_1", 0) is True + body = handler.wfile.getvalue().decode("utf-8") + assert "id: run_1:1\n" in body + assert "event: token\n" in body + assert "id: run_1:3\n" in body + assert "event: done\n" in body + # The metering row is filtered from the replay burst. + assert "event: metering\n" not in body + assert "id: run_1:2\n" not in body + + def test_active_stream_replay_keeps_items_for_new_run_with_same_seq_range(monkeypatch): import api.routes as routes From 5cbc5f7106864a7c2d88275b2cd865369039e136 Mon Sep 17 00:00:00 2001 From: laitekin <2625707287@qq.com> Date: Tue, 25 Aug 2026 18:13:38 +0800 Subject: [PATCH 02/47] fix(run-journal): preserve cursor ids for unjournaled metering --- api/gateway_chat.py | 2 +- api/routes.py | 8 +-- api/streaming.py | 2 +- tests/test_run_journal_routes.py | 55 +++++++++++++++++++ tests/test_run_journal_streaming_static.py | 2 +- tests/test_stage364_opus_live_sse_event_id.py | 4 +- 6 files changed, 64 insertions(+), 9 deletions(-) diff --git a/api/gateway_chat.py b/api/gateway_chat.py index c759c68d..0807af48 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -981,7 +981,7 @@ def _run_gateway_chat_streaming( except Exception: logger.debug("Failed to note gateway event_id %s for stream %s", event_id, stream_id, exc_info=True) try: - queue_item = (event, data, event_id) if event_id and hasattr(q, "subscribe_with_snapshot") else (event, data) + queue_item = (event, data, event_id) if hasattr(q, "subscribe_with_snapshot") else (event, data) q.put_nowait(queue_item) except Exception: logger.debug("Failed to put gateway event to queue") diff --git a/api/routes.py b/api/routes.py index 062a3772..5a6a319f 100644 --- a/api/routes.py +++ b/api/routes.py @@ -19031,14 +19031,14 @@ def _handle_sse_stream(handler, parsed): continue if len(item) >= 3: event, data, queued_event_id = item[0], item[1], item[2] + event_id = queued_event_id else: event, data = item - queued_event_id = STREAM_LAST_EVENT_ID.get(stream_id) + event_id = STREAM_LAST_EVENT_ID.get(stream_id) # Stage-364: emit `id:` from STREAM_LAST_EVENT_ID side-channel so # the frontend's `_lastRunJournalSeq` cursor advances during live # streaming. Without this, mid-stream error→replay would arrive # with after_seq=0 and double-render every journaled event. - event_id = queued_event_id or STREAM_LAST_EVENT_ID.get(stream_id) event_seq = _run_journal_same_run_seq(event_id, stream_id) if replay_cutoff_seq is not None and event_seq is not None and event_seq <= replay_cutoff_seq: continue @@ -19181,10 +19181,10 @@ def _handle_session_run_journal_stream_for_session(handler, parsed, session_id): continue if len(item) >= 3: event, data, queued_event_id = item[0], item[1], item[2] + event_id = queued_event_id else: event, data = item - queued_event_id = STREAM_LAST_EVENT_ID.get(active_stream_id) - event_id = queued_event_id or STREAM_LAST_EVENT_ID.get(active_stream_id) + event_id = STREAM_LAST_EVENT_ID.get(active_stream_id) event_seq = _run_journal_same_run_seq(event_id, active_stream_id) _is_terminal = event in SSE_RELAY_CLOSE_EVENTS _already_sent = ( diff --git a/api/streaming.py b/api/streaming.py index 29cda8be..5b8f4108 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -9600,7 +9600,7 @@ def _run_agent_streaming( except Exception: logger.debug("Failed to note event_id %s for stream %s", event_id, stream_id, exc_info=True) try: - queue_item = (event, data, event_id) if event_id and hasattr(q, "subscribe_with_snapshot") else (event, data) + queue_item = (event, data, event_id) if hasattr(q, "subscribe_with_snapshot") else (event, data) q.put_nowait(queue_item) except Exception: logger.debug("Failed to put event to queue") diff --git a/tests/test_run_journal_routes.py b/tests/test_run_journal_routes.py index 95077d2d..ad1ccb5b 100644 --- a/tests/test_run_journal_routes.py +++ b/tests/test_run_journal_routes.py @@ -797,6 +797,61 @@ def test_replay_run_journal_skips_metering_rows(monkeypatch): assert "id: run_1:2\n" not in body +def test_live_unjournaled_metering_does_not_inherit_latest_event_id(monkeypatch): + import api.routes as routes + + class FakeStream: + def __init__(self): + self.q = queue.Queue() + self.q.put_nowait(("metering", {"tps": 47.3}, None)) + self.q.put_nowait(("token", {"text": "visible"}, "run_1:1")) + self.q.put_nowait(("stream_end", {}, "run_1:2")) + self.unsubscribed = False + + def subscribe_with_snapshot(self): + return self.q, {"last_event_id": "run_1:1", "offline_buffered_events": 3} + + def unsubscribe(self, q): + self.unsubscribed = q is self.q + + class Handler: + def __init__(self): + self.wfile = io.BytesIO() + self.headers = {} + + def send_response(self, _code): + pass + + def send_header(self, _name, _value): + pass + + def end_headers(self): + pass + + handler = Handler() + stream = FakeStream() + monkeypatch.setattr( + routes, + "find_run_summary", + lambda stream_id: { + "session_id": "session_1", + "run_id": stream_id, + "terminal": False, + }, + ) + monkeypatch.setattr(routes, "read_run_events", lambda *_args, **_kwargs: {"events": []}) + monkeypatch.setitem(routes.STREAM_LAST_EVENT_ID, "run_1", "run_1:1") + monkeypatch.setitem(routes.STREAMS, "run_1", stream) + + routes._handle_sse_stream(handler, urlparse("/api/chat/stream?stream_id=run_1")) + + body = handler.wfile.getvalue().decode("utf-8") + frames = [frame for frame in body.split("\n\n") if frame.strip()] + assert frames[0] == "event: metering\ndata: {\"tps\": 47.3}" + assert "id: run_1:1\nevent: token\ndata: {\"text\": \"visible\"}" in frames + assert stream.unsubscribed is True + + def test_active_stream_replay_keeps_items_for_new_run_with_same_seq_range(monkeypatch): import api.routes as routes diff --git a/tests/test_run_journal_streaming_static.py b/tests/test_run_journal_streaming_static.py index 7224dbcf..10fdb63d 100644 --- a/tests/test_run_journal_streaming_static.py +++ b/tests/test_run_journal_streaming_static.py @@ -26,7 +26,7 @@ def test_streaming_journals_sse_events_before_queue_delivery(): assert put_idx < journal_idx < queue_idx assert "Failed to append run journal event" in block - assert "queue_item = (event, data, event_id) if event_id and hasattr(q, \"subscribe_with_snapshot\") else (event, data)" in block + assert 'queue_item = (event, data, event_id) if hasattr(q, "subscribe_with_snapshot") else (event, data)' in block diff --git a/tests/test_stage364_opus_live_sse_event_id.py b/tests/test_stage364_opus_live_sse_event_id.py index bc141f5c..83885895 100644 --- a/tests/test_stage364_opus_live_sse_event_id.py +++ b/tests/test_stage364_opus_live_sse_event_id.py @@ -57,7 +57,7 @@ def test_stream_channel_queue_item_carries_per_event_id_with_legacy_fallback(): """StreamChannel queue items need per-frame ids; legacy queues stay 2-tuples.""" put_def_idx = STREAMING_PY.find("def put(event, data):") put_body = STREAMING_PY[put_def_idx:put_def_idx + 2500] - assert 'queue_item = (event, data, event_id) if event_id and hasattr(q, "subscribe_with_snapshot") else (event, data)' in put_body, ( + assert 'queue_item = (event, data, event_id) if hasattr(q, "subscribe_with_snapshot") else (event, data)' in put_body, ( "StreamChannel events must carry their own event_id while legacy queue " "consumers retain the 2-tuple shape" ) @@ -69,7 +69,7 @@ def test_gateway_queue_item_carries_per_event_id_with_legacy_fallback(): put_def_idx = GATEWAY_CHAT_PY.find("def put_gateway_event(event, data):") assert put_def_idx != -1, "put_gateway_event(event, data) not found" put_body = GATEWAY_CHAT_PY[put_def_idx:put_def_idx + 1800] - assert 'queue_item = (event, data, event_id) if event_id and hasattr(q, "subscribe_with_snapshot") else (event, data)' in put_body, ( + assert 'queue_item = (event, data, event_id) if hasattr(q, "subscribe_with_snapshot") else (event, data)' in put_body, ( "Gateway live events must carry their own event_id for StreamChannel " "subscribers while preserving legacy queue compatibility" ) From 87338e8098611532fc3da6dde1f8a513b2ee1003 Mon Sep 17 00:00:00 2001 From: laitekin <2625707287@qq.com> Date: Mon, 21 Sep 2026 22:33:14 +0800 Subject: [PATCH 03/47] test(run-journal): bind replay fixture to live stream registry --- tests/test_issue4812_session_sse_stream.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_issue4812_session_sse_stream.py b/tests/test_issue4812_session_sse_stream.py index dc3c2c3f..27f9c771 100644 --- a/tests/test_issue4812_session_sse_stream.py +++ b/tests/test_issue4812_session_sse_stream.py @@ -610,6 +610,8 @@ def test_session_route_replay_skips_metering_rows(monkeypatch): ) monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda *_args, **_kwargs: "run_active") monkeypatch.setattr(routes, "STREAMS", {"run_active": stream}) + import api.config as config + monkeypatch.setattr(config, "STREAMS", {"run_active": stream}) # Both the pre-attach replay and the post-attach reconciliation draw from the # same journal; a metering row among them must never reach the SSE body. monkeypatch.setattr( From 9b3c749302e883fc603c9fbe0bbc8e7639f9eef8 Mon Sep 17 00:00:00 2001 From: laitekin <2625707287@qq.com> Date: Mon, 21 Sep 2026 23:03:24 +0800 Subject: [PATCH 04/47] docs(run-journal): specify live-only metering and optional writes (#7272) --- api/run_journal.py | 2 +- docs/rfcs/session-sse-contract-v1.md | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/api/run_journal.py b/api/run_journal.py index 677876eb..a088c13d 100644 --- a/api/run_journal.py +++ b/api/run_journal.py @@ -454,7 +454,7 @@ class RunJournalWriter: self._path = _run_path(self.session_id, self.run_id, session_dir=self.session_dir) self._lock = _lock_for(self._path) - def append_sse_event(self, event_name: str, payload=None) -> dict: + def append_sse_event(self, event_name: str, payload=None) -> dict | None: # Live-UI-only telemetry (metering) has no recovery value in the journal: # nothing reads those rows back for recovery, and journaling them at ~10 Hz # on marathon runs balloons the durable file (12+ MB of a single 18 MB run diff --git a/docs/rfcs/session-sse-contract-v1.md b/docs/rfcs/session-sse-contract-v1.md index 65b13366..e6248e03 100644 --- a/docs/rfcs/session-sse-contract-v1.md +++ b/docs/rfcs/session-sse-contract-v1.md @@ -183,7 +183,7 @@ when integrating with the live chat SSE relay. | `cancel` | Run cancelled | | `done` | Turn finalized (session payload); title/`stream_end` may follow | | `stream_end` | SSE fence — close the client EventSource | -| `metering` | Token/cost metering snapshot | +| `metering` | Best-effort live token/cost metering snapshot; not journal-replayed | | `context_status` | Context window / usage status | | `goal` | Goal / plan card update | | `goal_continue` | Goal continuation signal | @@ -228,6 +228,31 @@ A future implementation must replay from the run journal via the existing `_replay_run_journal()` path (in `api/routes.py`) and fall back to the snapshot mechanism when journal entries are unavailable for a given cursor. +### Live-only metering (implemented replay behavior) + +`metering` is best-effort live telemetry, not durable recovery content. +`RunJournalWriter.append_sse_event()` returns `None` for metering without +writing a row or allocating a sequence number. Other journaled events retain +contiguous sequence numbers. Local-agent and gateway producers carry an +explicit per-frame event ID, including `None`, through `StreamChannel`; +an unjournaled metering frame emits no new SSE `id:` and must not borrow the +previous frame's journal ID. The last-ID fallback is limited to legacy +two-element queue entries. + +Existing journals are not rewritten. Direct readers (`read_run_events()` and +`read_session_run_events()`) retain legacy metering rows for cursor validation +and gap/coverage checks. Both run-level and per-session journal replay emitters +apply `journal_replay_visible()` to omit those rows from reconnect delivery. +Consequently, replayed legacy event IDs can have gaps where metering was +filtered; clients must not infer missing durable content from those gaps. +Metering missed while disconnected is not recovered from the journal; fresh +live updates remain available. This exception does not remove token, reasoning, +tool, or terminal events from durable replay. + +Coverage: `tests/test_run_journal.py`, `tests/test_run_journal_routes.py`, +`tests/test_issue4812_session_sse_stream.py`, and +`tests/test_stage364_opus_live_sse_event_id.py`. + ## Snapshot fallback When the `Last-Event-ID` cursor is evicted, expired, unknown, or refers to a From dd747b2f20f4131cefd0c37e413a915ed2e957c5 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 22 Sep 2026 04:07:06 +0000 Subject: [PATCH 05/47] docs(changelog): stamp #7291 run-journal metering fix --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac7e397..6e30cbcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ ### Fixed +- **A long-running turn no longer replays a stale token count after a reload.** The run-journal + recorded live metering frames, so reattaching to a stream — or reloading a tab mid-turn — + could replay a snapshot from earlier in the same run and briefly show token/TPS figures that + had already been superseded. Metering is now live-only and never journaled, and replayed + frames no longer carry an event id that could advance the client's resume cursor past real + content. Reattach and reload now show the current numbers for the turn in progress. Thanks + @laitekin. (#7291) + - **Steering a conversation works again after the context is compressed.** When compression rotated `agent.session_id`, a steer could no longer find the active worker: it was either silently dropped or accepted and never delivered. Steers now resolve the owning worker From 39a5d2062cea3adbbae83373874eb87fb7ae6763 Mon Sep 17 00:00:00 2001 From: danpark Date: Mon, 21 Sep 2026 03:35:40 -0400 Subject: [PATCH 06/47] fix(gateway_watcher): park idle poll loop when no SSE subscribers Stop the permanent ~7% core burn from re-fingerprinting state.db every 5s and spinning on 0.1s stop-checks while nobody is listening. Block on Event.wait for the poll interval, and sleep on _idle_wake until the first subscriber arrives (maint #3035). Co-authored-by: Cursor --- api/gateway_watcher.py | 36 +++++- tests/test_gateway_watcher_idle_park.py | 158 ++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 tests/test_gateway_watcher_idle_park.py diff --git a/api/gateway_watcher.py b/api/gateway_watcher.py index b52f5b0c..635969ec 100644 --- a/api/gateway_watcher.py +++ b/api/gateway_watcher.py @@ -204,6 +204,8 @@ class GatewayWatcher: self._subscribers: list[queue.Queue] = [] self._sub_lock = threading.Lock() self._stop_event = threading.Event() + # Wakes a poll loop parked because nobody is subscribed (subscribe/stop). + self._idle_wake = threading.Event() self._thread: threading.Thread | None = None self._hermes_home = Path(hermes_home).expanduser().resolve() if hermes_home else None self._state_db_path = ( @@ -244,6 +246,7 @@ class GatewayWatcher: def stop(self): """Stop the watcher thread.""" self._stop_event.set() + self._idle_wake.set() # unpark if waiting for the first subscriber # Wake up any subscribers with self._sub_lock: for q in self._subscribers: @@ -255,6 +258,11 @@ class GatewayWatcher: self._thread.join(timeout=3) self._thread = None + def _has_subscribers(self) -> bool: + """Return True when at least one SSE client is attached.""" + with self._sub_lock: + return bool(self._subscribers) + def subscribe(self) -> queue.Queue: """Subscribe to change events. Returns a queue.Queue. Events are dicts: {'type': 'sessions_changed', 'sessions': [...]} @@ -273,6 +281,9 @@ class GatewayWatcher: q.put_nowait(None) except Exception: logger.debug("Failed to send stop sentinel to late subscriber") + # Wake a poll loop parked with zero subscribers so the first SSE client + # gets a prompt initial projection instead of waiting out POLL_INTERVAL. + self._idle_wake.set() return q def unsubscribe(self, q: queue.Queue): @@ -355,18 +366,31 @@ class GatewayWatcher: return True def _poll_loop(self): - """Main polling loop. Runs in a daemon thread.""" + """Main polling loop. Runs in a daemon thread. + + With no SSE subscribers there is nobody to notify, so the loop parks on + ``_idle_wake`` instead of re-fingerprinting ``state.db`` every few + seconds (maint #3035). While subscribed it blocks once on + ``_stop_event.wait(POLL_INTERVAL)`` — a single timer sleep that returns + immediately when ``stop()`` sets the event, replacing the previous + 10 wakeups/sec ``time.sleep(0.1)`` spin. + """ while not self._stop_event.is_set(): + if not self._has_subscribers(): + self._idle_wake.clear() + # subscribe() may have raced between the check and clear; if so + # the flag is already set again and we must not park. + if self._has_subscribers() or self._stop_event.is_set(): + continue + self._idle_wake.wait() + continue + try: self._poll_once() except Exception: logger.debug("Error in gateway watcher poll loop", exc_info=True) - # Sleep in small increments so we can stop promptly - for _ in range(self.POLL_INTERVAL * 10): - if self._stop_event.is_set(): - return - time.sleep(0.1) + self._stop_event.wait(self.POLL_INTERVAL) # ── Module-level watcher registry ────────────────────────────────────────── diff --git a/tests/test_gateway_watcher_idle_park.py b/tests/test_gateway_watcher_idle_park.py new file mode 100644 index 00000000..21a606da --- /dev/null +++ b/tests/test_gateway_watcher_idle_park.py @@ -0,0 +1,158 @@ +"""Regression: GatewayWatcher must not burn CPU when nobody is listening. + +Maint #3035 / production evidence: with zero inbound traffic the daemon still +re-fingerprinted every sessions row every 5s and woke 10×/s on ``time.sleep(0.1)`` +stop-checks, holding ~7% of a core forever. + +The poll loop must: + 1. park (no fingerprint / no projection) while subscriber count is zero, and + 2. wait on ``threading.Event.wait`` for the poll interval — not a spin of + short sleeps — so a quiet process costs essentially nothing. +""" +from __future__ import annotations + +import importlib +import sqlite3 +import time +from pathlib import Path + + +def _make_db(tmp_path: Path): + db = tmp_path / "state.db" + conn = sqlite3.connect(str(db)) + conn.executescript( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + session_source TEXT, + model TEXT, + started_at REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + parent_session_id TEXT, + message_count INTEGER DEFAULT 0, + title TEXT, + archived INTEGER DEFAULT 0 + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + timestamp REAL NOT NULL + ); + """ + ) + conn.execute( + "INSERT INTO sessions (id, source, model, started_at, message_count, title) " + "VALUES ('tg1', 'telegram', 'm', ?, 1, 'Chat')", + (time.time(),), + ) + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp) " + "VALUES ('tg1', 'user', 'x', ?)", + (time.time(),), + ) + conn.commit() + return db, conn + + +def test_poll_loop_uses_blocking_event_wait_not_spin_sleep(): + """Source contract: the idle sleep must be one Event.wait, not 50× sleep(0.1).""" + src = ( + Path(__file__).resolve().parents[1] / "api" / "gateway_watcher.py" + ).read_text(encoding="utf-8") + start = src.index("def _poll_loop(self)") + end = src.index("\n# ── Module-level watcher registry", start) + body = src[start:end] + # Strip docstrings so a historical mention of the old spin cannot false-fail. + code = body + while '"""' in code: + a = code.index('"""') + b = code.index('"""', a + 3) + 3 + code = code[:a] + code[b:] + assert "time.sleep(0.1)" not in code, ( + "0.1s stop-check spin burns syscalls forever; use Event.wait(POLL_INTERVAL)" + ) + assert "self._stop_event.wait(" in code, ( + "poll interval must block on _stop_event.wait so stop() wakes immediately" + ) + assert "_idle_wake" in code and "_has_subscribers" in code, ( + "zero-subscriber path must park on _idle_wake instead of fingerprinting" + ) + + +def test_poll_loop_parks_without_subscribers(tmp_path, monkeypatch): + """Zero subscribers → zero fingerprint work; subscribe wakes and polls resume.""" + gw = importlib.import_module("api.gateway_watcher") + db, _conn = _make_db(tmp_path) + + polls: list[float] = [] + real_poll_once = gw.GatewayWatcher._poll_once + + def tracing_poll_once(self, *args, **kwargs): + polls.append(time.monotonic()) + return real_poll_once(self, *args, **kwargs) + + monkeypatch.setattr(gw.GatewayWatcher, "_poll_once", tracing_poll_once) + + watcher = gw.GatewayWatcher(state_db_path=db) + # Keep the active interval short so the subscribed phase is fast to assert. + watcher.POLL_INTERVAL = 0.05 + watcher.start() + try: + time.sleep(0.25) + assert polls == [], ( + "with no SSE subscribers the watcher must not fingerprint/poll state.db; " + f"got {len(polls)} poll(s)" + ) + + q = watcher.subscribe() + deadline = time.monotonic() + 2.0 + while not polls and time.monotonic() < deadline: + time.sleep(0.01) + assert polls, "first subscribe must wake the parked poll loop promptly" + + # Let at least one more subscribed cycle land, then detach. + before_unsub = len(polls) + deadline = time.monotonic() + 2.0 + while len(polls) <= before_unsub and time.monotonic() < deadline: + time.sleep(0.01) + assert len(polls) > before_unsub, "subscribed watcher must keep polling" + + watcher.unsubscribe(q) + # Drain any in-flight poll that started before unsubscribe, then prove + # the loop re-parks (no further growth). + time.sleep(watcher.POLL_INTERVAL + 0.05) + settled = len(polls) + time.sleep(0.3) + assert len(polls) == settled, ( + "after the last subscriber leaves, polling must park again; " + f"grew from {settled} to {len(polls)}" + ) + finally: + watcher.stop() + assert not watcher.is_alive() or ( + watcher._thread is None or not watcher._thread.is_alive() + ) + + +def test_stop_unparks_idle_watcher_promptly(tmp_path): + """stop() must release a watcher parked with no subscribers within join timeout.""" + gw = importlib.import_module("api.gateway_watcher") + db, _conn = _make_db(tmp_path) + watcher = gw.GatewayWatcher(state_db_path=db) + watcher.POLL_INTERVAL = 30 # would hang badly if stop relied on the interval alone + watcher.start() + try: + # Confirm we are parked (thread alive, no work). + assert watcher.is_alive() + time.sleep(0.05) + t0 = time.monotonic() + watcher.stop() + elapsed = time.monotonic() - t0 + assert elapsed < 2.0, f"stop() blocked {elapsed:.2f}s on an idle parked watcher" + assert not watcher.is_alive() + finally: + watcher.stop() From 120f7a0cf9249bd723675a5587cada6aa76f1493 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 22 Sep 2026 06:08:07 +0000 Subject: [PATCH 07/47] docs(changelog): stamp #7694 gateway-watcher idle park --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e30cbcf..790e0ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ ### Fixed +- **The gateway watcher no longer polls `state.db` around the clock with nobody listening.** + Its poll loop re-fingerprinted the gateway state database every few seconds whether or not + any SSE client was attached, and slept in 0.1s increments — roughly 10 wakeups a second, all + day, on an idle server. The loop now parks on an event when there are no subscribers and is + woken by the first one, so an idle instance does no polling work at all; while subscribed it + waits on a single timer that still returns immediately on shutdown. Connecting a client + remains prompt — the first subscriber unparks the loop rather than waiting out the poll + interval. Thanks @DevNexsler. (#7694) + - **A long-running turn no longer replays a stale token count after a reload.** The run-journal recorded live metering frames, so reattaching to a stream — or reloading a tab mid-turn — could replay a snapshot from earlier in the same run and briefly show token/TPS figures that From 30fb4edc1fc232680a40aa5c0c1a196ca114d71b Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 22 Sep 2026 06:17:25 +0000 Subject: [PATCH 08/47] fix(i18n): stop double-rendering the skip-all lightning icon (#7701) The approval card's Skip-all button drew its icon twice: once from the .approval-btn-icon span in static/index.html and again from the leading glyph baked into the approval_skip_all translation in 14 of 15 locales. pt already shipped without the prefix, which is the correct shape. Strip the glyph from every locale value so the markup span is the single icon source, matching Allow once / Allow session / Always allow / Deny, which all pair an icon element with a glyph-free label. test_vietnamese_locale.py asserted the old prefixed string verbatim; its expectation is updated to the corrected value. --- CHANGELOG.md | 7 +++++++ static/i18n.js | 28 ++++++++++++++-------------- tests/test_vietnamese_locale.py | 2 +- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 790e0ca0..9db93647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ ### Fixed +- **The approval card's "Skip all this session" button no longer shows two lightning bolts.** + The button rendered its ⚡ twice — once from the icon span in the markup and again from the + translated label, which carried its own leading glyph in 14 of 15 locales. The icon now comes + only from the markup, matching every sibling button on the card (Allow once / Allow session / + Always allow / Deny all pair an icon element with a glyph-free label), and translators no + longer carry the symbol in their strings. (#7701) + - **The gateway watcher no longer polls `state.db` around the clock with nobody listening.** Its poll loop re-fingerprinted the gateway state database every few seconds whether or not any SSE client was attached, and slept in 0.1s increments — roughly 10 wakeups a second, all diff --git a/static/i18n.js b/static/i18n.js index dcca45af..0192fd5c 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1770,7 +1770,7 @@ const LOCALES = { yolo_disabled: 'YOLO mode OFF', yolo_pill_label: 'YOLO', yolo_pill_title_active: 'YOLO mode active — click to disable', - approval_skip_all: '⚡ Skip all this session', + approval_skip_all: 'Skip all this session', approval_skip_all_title: 'Skip all approval prompts for this session', // composer action tooltips composer_send: 'Send message', @@ -3572,7 +3572,7 @@ const LOCALES = { yolo_disabled: 'Modalità YOLO OFF', yolo_pill_label: 'YOLO', yolo_pill_title_active: 'Modalità YOLO attiva — clicca per disabilitare', - approval_skip_all: '⚡ Salta tutto questa sessione', + approval_skip_all: 'Salta tutto questa sessione', approval_skip_all_title: 'Salta tutte le richieste di approvazione per questa sessione', // composer action tooltips composer_send: 'Invia messaggio', @@ -5382,7 +5382,7 @@ const LOCALES = { yolo_disabled: 'YOLO モード OFF', yolo_pill_label: 'YOLO', yolo_pill_title_active: 'YOLO モード有効 — クリックで無効化', - approval_skip_all: '⚡ このセッションはすべてスキップ', + approval_skip_all: 'このセッションはすべてスキップ', approval_skip_all_title: 'このセッションのすべての承認プロンプトをスキップ', // composer action tooltips composer_send: 'メッセージを送信', @@ -7063,7 +7063,7 @@ const LOCALES = { yolo_disabled: 'YOLO режим ВЫКЛ', yolo_pill_label: 'YOLO', yolo_pill_title_active: 'YOLO режим активен — нажмите для отключения', - approval_skip_all: '⚡ Пропустить все в этом сеансе', + approval_skip_all: 'Пропустить все в этом сеансе', approval_skip_all_title: 'Пропустить все запросы подтверждения в этом сеансе', // composer action tooltips composer_send: 'Отправить сообщение', @@ -8830,7 +8830,7 @@ const LOCALES = { yolo_disabled: 'Modo YOLO desactivado', yolo_pill_label: 'YOLO', yolo_pill_title_active: 'Modo YOLO activo — haz clic para desactivar', - approval_skip_all: '⚡ Saltar todo en esta sesión', + approval_skip_all: 'Saltar todo en esta sesión', approval_skip_all_title: 'Saltar todas las solicitudes de aprobación en esta sesión', // composer action tooltips composer_send: 'Enviar mensaje', @@ -10236,7 +10236,7 @@ const LOCALES = { yolo_disabled: 'YOLO-Modus AUS', yolo_pill_label: 'YOLO', yolo_pill_title_active: 'YOLO-Modus aktiv — Klicken zum Deaktivieren', - approval_skip_all: '⚡ Alle in dieser Sitzung überspringen', + approval_skip_all: 'Alle in dieser Sitzung überspringen', approval_skip_all_title: 'Alle Genehmigungsanfragen in dieser Sitzung überspringen', // composer action tooltips composer_send: 'Nachricht senden', @@ -12312,7 +12312,7 @@ const LOCALES = { yolo_disabled: 'YOLO 模式已关闭', yolo_pill_label: 'YOLO', yolo_pill_title_active: 'YOLO 模式激活 — 点击关闭', - approval_skip_all: '⚡ 本次会话全部跳过', + approval_skip_all: '本次会话全部跳过', approval_skip_all_title: '跳过本次会话的所有审批提示', // composer action tooltips composer_send: '发送消息', @@ -14208,7 +14208,7 @@ const LOCALES = { yolo_disabled: 'YOLO 模式已關閉', yolo_pill_label: 'YOLO', yolo_pill_title_active: 'YOLO 模式已啟用,點選即可關閉', - approval_skip_all: '⚡ 本次工作階段全部跳過', + approval_skip_all: '本次工作階段全部跳過', approval_skip_all_title: '略過本次工作階段的所有核准提示', // composer action tooltips composer_send: '傳送訊息', @@ -17540,7 +17540,7 @@ const LOCALES = { yolo_disabled: 'YOLO 모드 꺼짐', yolo_pill_label: 'YOLO', yolo_pill_title_active: 'YOLO 모드 활성 — 클릭하여 비활성화', - approval_skip_all: '⚡ 이 세션에서 모두 건너뛰기', + approval_skip_all: '이 세션에서 모두 건너뛰기', approval_skip_all_title: '이 세션의 모든 승인 요청 건너뛰기', // composer action tooltips composer_send: '메시지 보내기', @@ -19412,7 +19412,7 @@ const LOCALES = { yolo_disabled: 'Mode YOLO désactivé', yolo_pill_label: 'YOLO', yolo_pill_title_active: 'Mode YOLO actif — cliquez pour désactiver', - approval_skip_all: '⚡ Ignorer tout cette session', + approval_skip_all: 'Ignorer tout cette session', approval_skip_all_title: 'Ignorer toutes les demandes d\'approbation pour cette session', composer_send: 'Envoyer le message', composer_queue: 'Mettre en file d\'attente', @@ -19540,7 +19540,7 @@ const LOCALES = { add_failed: 'Přidání selhalo: ', agent_soul: 'Duch agenta', all_uploads_failed: (n) => `Všech ${n} nahrávání selhalo`, - approval_skip_all: '⚡ Přeskočit vše pro tuto relaci', + approval_skip_all: 'Přeskočit vše pro tuto relaci', approval_skip_all_title: 'Přeskočit všechny výzvy ke schválení pro tuto relaci', archive_extracted: (n, c) => `Rozbaleno ${n} souborů z ${c} archivů`, auth_ack_save_failed: 'Nepodařilo se aktualizovat potvrzení: ', @@ -22894,7 +22894,7 @@ const LOCALES = { yolo_disabled: 'YOLO modu KAPALI', yolo_pill_label: 'YOLO', yolo_pill_title_active: 'YOLO modu etkin — devre dışı bırakmak için tıklayın', - approval_skip_all: '⚡ Tüm bu oturumu atla', + approval_skip_all: 'Tüm bu oturumu atla', approval_skip_all_title: 'Bu oturuma ilişkin tüm onay istemlerini atla', // composer action tooltips composer_send: 'Mesaj gönder', @@ -24778,7 +24778,7 @@ const LOCALES = { yolo_disabled: 'Tryb YOLO WYŁĄCZONY', yolo_pill_label: 'YOLO', yolo_pill_title_active: 'Tryb YOLO aktywny — kliknij, aby wyłączyć', - approval_skip_all: '⚡ Pomiń wszystkie w tej sesji', + approval_skip_all: 'Pomiń wszystkie w tej sesji', approval_skip_all_title: 'Pomiń wszystkie monity o aprobatę w tej sesji', // composer action tooltips composer_send: 'Wyślij wiadomość', @@ -26411,7 +26411,7 @@ const LOCALES = { approval_gateway_unsupported: 'Phê duyệt cần gateway mới hơn. Hãy nâng cấp Hermes gateway đang kết nối để bật tính năng này.', approval_gateway_unsupported_label: 'Không hỗ trợ phê duyệt', approval_pending_count: (n) => `1 trong ${n} đang chờ`, - approval_skip_all: '⚡ Bỏ qua tất cả trong phiên này', + approval_skip_all: 'Bỏ qua tất cả trong phiên này', approval_skip_all_title: 'Bỏ qua mọi yêu cầu phê duyệt cho phiên này', checkpoint_title: 'Checkpoint', checkpoint_empty: 'Không tìm thấy checkpoint nào cho workspace này.', diff --git a/tests/test_vietnamese_locale.py b/tests/test_vietnamese_locale.py index 1c86cba6..4107b762 100644 --- a/tests/test_vietnamese_locale.py +++ b/tests/test_vietnamese_locale.py @@ -88,7 +88,7 @@ def test_vietnamese_locale_includes_representative_translations(): expected = [ "settings_heading_title: 'Trung tâm điều khiển'", "settings_heading_subtitle: 'Tùy chọn, công cụ hội thoại và điều khiển hệ thống.'", - "approval_skip_all: '⚡ Bỏ qua tất cả trong phiên này'", + "approval_skip_all: 'Bỏ qua tất cả trong phiên này'", "checkpoint_title: 'Checkpoint'", "composer_send: 'Gửi tin nhắn'", "gateway_restart: 'Khởi động lại'", From b2c7c94e5ac53eb82667088f0a32f8d6a0399bb3 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Mon, 14 Sep 2026 17:36:44 +0300 Subject: [PATCH 09/47] Fix gateway approval denial resurrection --- api/route_approvals.py | 22 +++++++++++- tests/test_gateway_approval_legacy_path.py | 42 ++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/api/route_approvals.py b/api/route_approvals.py index eb2761a0..6deb3217 100644 --- a/api/route_approvals.py +++ b/api/route_approvals.py @@ -558,7 +558,27 @@ def retire_gateway_pending_mirror( return changed for match in retired: entries.remove(match) - if normalized_run_id and not approval_id: + if normalized_run_id: + # A successful response may arrive with the exact approval_id. The + # mirror and its producer queue are one lifecycle: leaving the + # producer behind lets reconciliation recreate the just-retired + # mirror on the next poll (the completed-child resurrection bug). + # Remove only the matching approval for an active run; when the + # caller retires a run without an approval_id, retire the whole run. + def _same_retired_run_entry(entry) -> bool: + data = getattr(entry, "data", None) or {} + if str(data.get("run_id") or "").strip() != normalized_run_id: + return False + if not approval_id: + return True + queued_approval_id = str(data.get("approval_id") or "").strip() + return not queued_approval_id or queued_approval_id == approval_id + + retained_gateway_queue = [ + entry for entry in gateway_queue + if not _same_retired_run_entry(entry) + ] + gateway_queue_changed = len(retained_gateway_queue) != len(gateway_queue) if retained_gateway_queue: _gateway_queues[session_key] = retained_gateway_queue else: diff --git a/tests/test_gateway_approval_legacy_path.py b/tests/test_gateway_approval_legacy_path.py index dabbf7ec..6a365d55 100644 --- a/tests/test_gateway_approval_legacy_path.py +++ b/tests/test_gateway_approval_legacy_path.py @@ -873,3 +873,45 @@ def test_legacy_approval_without_run_id_retires_locally(): r._pending.pop(session_id, None) r._gateway_queues.pop(session_id, None) _STREAM_RUN_IDS.pop(stream_id, None) + + +def test_deny_retires_run_producer_so_reconciliation_cannot_resurrect_mirror(): + """Deny must consume the producer as well as its WebUI mirror.""" + from types import SimpleNamespace + from api import route_approvals as ra + + sid = "sess-deny-no-resurrection" + approval_id = "approval-deny-no-resurrection" + run_id = "run-deny-no-resurrection" + approval = { + "approval_id": approval_id, + "run_id": run_id, + "command": "echo pricing", + "description": "Run pricing probe", + } + producer = SimpleNamespace(data=dict(approval), event=threading.Event(), result=None) + try: + with ra._lock: + ra._pending.pop(sid, None) + ra._gateway_queues[sid] = [producer] + ra.submit_gateway_pending_mirror(sid, dict(approval)) + mirror = ra.gateway_pending_mirror(sid, approval_id=approval_id, run_id=run_id) + assert mirror is not None + + assert ra.retire_gateway_pending_mirror( + sid, + approval_id=approval_id, + run_id=run_id, + mirror_token=mirror[ra._GATEWAY_MIRROR_TOKEN], + ) + + # These are the same observations made by subsequent HTTP polling. + for _ in range(2): + with ra._lock: + ra.reconcile_gateway_pending_mirror_locked(sid) + assert sid not in ra._pending + assert sid not in ra._gateway_queues + finally: + with ra._lock: + ra._pending.pop(sid, None) + ra._gateway_queues.pop(sid, None) From 571ef3eec23f43a175f8e2b2f6e74cc6eee9e280 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Mon, 14 Sep 2026 20:12:33 +0300 Subject: [PATCH 10/47] Address review findings: multi-producer coverage and lifecycle documentation Two repository-guide violations raised in review on #7570. Selection is what this fix changes, but the regression test seeded only the producer being retired. It now seeds a sibling approval on the same run and a producer belonging to an unrelated run, then asserts the target is consumed, both survivors remain queued, and reconciliation does not resurrect the denied approval's mirror. Mutation-checked so the new assertions have teeth: an over-aggressive filter that retires every same-run entry fails on the sibling assertion, and the unfixed implementation fails on the target assertion. Runtime behaviour also changed without tracked documentation of the invariant. The api/route_approvals.py module docstring now records the producer/mirror ownership direction and the retirement rules, including why an unattributable same-run producer is retired with its run. Additive commit: the previous commit is published and under review, so the reviewed history is preserved rather than rewritten. --- api/route_approvals.py | 17 ++++++ tests/test_gateway_approval_legacy_path.py | 61 +++++++++++++++++++--- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/api/route_approvals.py b/api/route_approvals.py index 6deb3217..94b879d6 100644 --- a/api/route_approvals.py +++ b/api/route_approvals.py @@ -2,6 +2,23 @@ State-extraction prelude to the routes.py split tracked in #1907. Extracts approval state, not handlers, by design. + +Gateway approvals carry two pieces of state with one lifecycle: + +* the **producer** entry in ``tools.approval._gateway_queues`` — the queued + approval itself, which owns the lifecycle; and +* the **mirror** entry in ``_pending`` — this module's projection of that + producer for the UI. + +Retirement resolves both together (``retire_gateway_pending_mirror``), and the +ownership direction matters: reconciliation rebuilds mirrors from live +producers, so a producer that survives its own resolution resurrects an +approval the user already dismissed. Retiring a specific ``approval_id`` +therefore consumes that approval's producer, retiring a whole run consumes the +run's producers, and a same-run producer carrying no ``approval_id`` is retired +with it — it cannot be attributed to anything else, and leaving it queued is +what permits the resurrection. Retiring one approval must leave sibling +approvals and other runs queued. """ import queue import threading diff --git a/tests/test_gateway_approval_legacy_path.py b/tests/test_gateway_approval_legacy_path.py index 6a365d55..93ac703b 100644 --- a/tests/test_gateway_approval_legacy_path.py +++ b/tests/test_gateway_approval_legacy_path.py @@ -876,24 +876,51 @@ def test_legacy_approval_without_run_id_retires_locally(): def test_deny_retires_run_producer_so_reconciliation_cannot_resurrect_mirror(): - """Deny must consume the producer as well as its WebUI mirror.""" + """Deny must consume the producer as well as its WebUI mirror. + + Selection is the behaviour under test, so the queue carries more than the + one entry being retired: a sibling approval for the same run, and a producer + belonging to a different run. Retiring one approval must consume exactly + that producer and leave both others queued. + """ from types import SimpleNamespace from api import route_approvals as ra sid = "sess-deny-no-resurrection" approval_id = "approval-deny-no-resurrection" run_id = "run-deny-no-resurrection" + sibling_approval_id = "approval-deny-sibling" + other_run_id = "run-deny-unrelated" approval = { "approval_id": approval_id, "run_id": run_id, "command": "echo pricing", "description": "Run pricing probe", } - producer = SimpleNamespace(data=dict(approval), event=threading.Event(), result=None) + sibling_approval = { + "approval_id": sibling_approval_id, + "run_id": run_id, + "command": "echo sibling", + "description": "Sibling approval on the same run", + } + other_run_approval = { + "approval_id": "approval-deny-other-run", + "run_id": other_run_id, + "command": "echo else", + "description": "Approval on an unrelated run", + } + + def producer(payload): + return SimpleNamespace(data=dict(payload), event=threading.Event(), result=None) + + target = producer(approval) + same_run_sibling = producer(sibling_approval) + other_run = producer(other_run_approval) try: with ra._lock: ra._pending.pop(sid, None) - ra._gateway_queues[sid] = [producer] + ra._gateway_queues[sid] = [target, same_run_sibling, other_run] + queued_before = list(ra._gateway_queues[sid]) ra.submit_gateway_pending_mirror(sid, dict(approval)) mirror = ra.gateway_pending_mirror(sid, approval_id=approval_id, run_id=run_id) assert mirror is not None @@ -905,12 +932,34 @@ def test_deny_retires_run_producer_so_reconciliation_cannot_resurrect_mirror(): mirror_token=mirror[ra._GATEWAY_MIRROR_TOKEN], ) - # These are the same observations made by subsequent HTTP polling. + assert queued_before == [target, same_run_sibling, other_run], ( + "the queue should have held all three producers before retirement" + ) + with ra._lock: + remaining = list(ra._gateway_queues.get(sid) or []) + assert target not in remaining, "the denied approval's producer must be consumed" + assert same_run_sibling in remaining, ( + "a sibling approval on the same run must not be consumed by retiring another approval" + ) + assert other_run in remaining, ( + "an unrelated run's producer must not be consumed" + ) + + # These are the same observations made by subsequent HTTP polling: the + # denied approval must not come back, and the survivors must stay live. + # `gateway_pending_mirror` acquires `_lock` itself, so it must be called + # outside the locked section. for _ in range(2): with ra._lock: ra.reconcile_gateway_pending_mirror_locked(sid) - assert sid not in ra._pending - assert sid not in ra._gateway_queues + assert ra.gateway_pending_mirror( + sid, approval_id=approval_id, run_id=run_id + ) is None, "reconciliation must not resurrect the denied approval's mirror" + with ra._lock: + refreshed = list(ra._gateway_queues.get(sid) or []) + assert target not in refreshed + assert same_run_sibling in refreshed + assert other_run in refreshed finally: with ra._lock: ra._pending.pop(sid, None) From 8c92d88225f9cbe5b20ae9cb52fc256c28a5dec1 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 07:13:34 +0300 Subject: [PATCH 11/47] Fix exact gateway approval producer resolution --- api/route_approvals.py | 90 +++++++++++++--------- api/routes.py | 11 ++- tests/test_approval_unblock.py | 79 +++++++++++++++++++ tests/test_gateway_approval_legacy_path.py | 17 ++-- 4 files changed, 147 insertions(+), 50 deletions(-) diff --git a/api/route_approvals.py b/api/route_approvals.py index 94b879d6..d956ab27 100644 --- a/api/route_approvals.py +++ b/api/route_approvals.py @@ -10,15 +10,13 @@ Gateway approvals carry two pieces of state with one lifecycle: * the **mirror** entry in ``_pending`` — this module's projection of that producer for the UI. -Retirement resolves both together (``retire_gateway_pending_mirror``), and the -ownership direction matters: reconciliation rebuilds mirrors from live -producers, so a producer that survives its own resolution resurrects an -approval the user already dismissed. Retiring a specific ``approval_id`` -therefore consumes that approval's producer, retiring a whole run consumes the -run's producers, and a same-run producer carrying no ``approval_id`` is retired -with it — it cannot be attributed to anything else, and leaving it queued is -what permits the resurrection. Retiring one approval must leave sibling -approvals and other runs queued. +A producer is consumed only by an explicit resolution operation, which assigns +its result and signals its waiter before reconciliation can observe it again. +``retire_gateway_pending_mirror`` cleans up the UI projection; it must not +silently delete a producer, because that would strand the agent thread parked +on the producer's event. For a provider-qualified response, +``resolve_gateway_pending_run`` matches the exact ``(run_id, approval_id)`` +pair, settles that producer, and preserves same-run siblings and other runs. """ import queue import threading @@ -534,8 +532,6 @@ def retire_gateway_pending_mirror( queue = _pending.get(session_key) entries = queue if isinstance(queue, list) else [queue] if queue else [] normalized_run_id = str(run_id or "").strip() - gateway_queue = _gateway_queues.get(session_key) or [] - retained_gateway_queue = gateway_queue gateway_queue_changed = False if approval_id: match = _gateway_pending_mirror_locked( @@ -560,13 +556,18 @@ def retire_gateway_pending_mirror( and not str(entry.get("run_id") or "").strip() ] if normalized_run_id: + gateway_queue = _gateway_queues.get(session_key) or [] retained_gateway_queue = [] for entry in gateway_queue: data = getattr(entry, "data", None) or {} if str(data.get("run_id") or "").strip() == normalized_run_id: - gateway_queue_changed = True continue retained_gateway_queue.append(entry) + gateway_queue_changed = len(retained_gateway_queue) != len(gateway_queue) + if retained_gateway_queue: + _gateway_queues[session_key] = retained_gateway_queue + else: + _gateway_queues.pop(session_key, None) if not retired and not gateway_queue_changed: head, total, changed = reconcile_gateway_pending_mirror_locked(session_key) _approval_sse_notify_locked(session_key, head, total) @@ -575,31 +576,6 @@ def retire_gateway_pending_mirror( return changed for match in retired: entries.remove(match) - if normalized_run_id: - # A successful response may arrive with the exact approval_id. The - # mirror and its producer queue are one lifecycle: leaving the - # producer behind lets reconciliation recreate the just-retired - # mirror on the next poll (the completed-child resurrection bug). - # Remove only the matching approval for an active run; when the - # caller retires a run without an approval_id, retire the whole run. - def _same_retired_run_entry(entry) -> bool: - data = getattr(entry, "data", None) or {} - if str(data.get("run_id") or "").strip() != normalized_run_id: - return False - if not approval_id: - return True - queued_approval_id = str(data.get("approval_id") or "").strip() - return not queued_approval_id or queued_approval_id == approval_id - - retained_gateway_queue = [ - entry for entry in gateway_queue - if not _same_retired_run_entry(entry) - ] - gateway_queue_changed = len(retained_gateway_queue) != len(gateway_queue) - if retained_gateway_queue: - _gateway_queues[session_key] = retained_gateway_queue - else: - _gateway_queues.pop(session_key, None) if entries: _pending[session_key] = entries else: @@ -853,6 +829,46 @@ def resolve_gateway_pending_local( return 1, head, total +def resolve_gateway_pending_run( + session_key: str, + approval_id: str, + run_id: str, + choice: str, + reason: str | None = None, +) -> tuple[int, dict | None, int]: + """Resolve one exact run-backed producer and wake its parked waiter.""" + target = None + approval_id = str(approval_id or "").strip() + run_id = str(run_id or "").strip() + if not approval_id or not run_id: + return 0, None, 0 + with _lock: + gateway_queue = _gateway_queues.get(session_key) or [] + retained_gateway_queue = [] + for entry in gateway_queue: + data = getattr(entry, "data", None) or {} + entry_approval_id = str(data.get("approval_id") or "").strip() + entry_run_id = str(data.get("run_id") or "").strip() + if target is None and entry_approval_id == approval_id and entry_run_id == run_id: + target = entry + else: + retained_gateway_queue.append(entry) + if retained_gateway_queue: + _gateway_queues[session_key] = retained_gateway_queue + else: + _gateway_queues.pop(session_key, None) + head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key) + _approval_sse_notify_locked(session_key, head, total) + if target is None: + return 0, head, total + target.result = choice + if reason: + target.reason = reason + target.event.set() + publish_session_list_changed("attention_resolved") + return 1, head, total + + def resolve_gateway_pending_local_no_run_mirror( session_key: str, approval_id: str, choice: str, reason: str | None = None ) -> tuple[bool, int, dict | None, int]: diff --git a/api/routes.py b/api/routes.py index 5a6a319f..883dbbd7 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10773,6 +10773,7 @@ from api.route_approvals import ( # noqa: F401 — re-exports for backward comp retire_gateway_pending_mirror, reconcile_gateway_pending_mirror_locked, resolve_gateway_pending_local, + resolve_gateway_pending_run, resolve_gateway_pending_local_all, resolve_gateway_pending_local_no_run_mirror, set_session_yolo_enabled, @@ -26219,7 +26220,6 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str, run_id: st found_target = False gateway_keys = [] local_gateway_approval_id = "" - gateway_head_matches_target = False with _lock: reconcile_gateway_pending_mirror_locked(sid) queue = _pending.get(sid) @@ -26291,9 +26291,6 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str, run_id: st gw_data = getattr(gw_entry, "data", None) or {} gw_approval_id = str(gw_data.get("approval_id") or "").strip() gw_run_id = str(gw_data.get("run_id") or "").strip() - gateway_head_matches_target = bool( - run_id and gw_approval_id == approval_id and gw_run_id == run_id - ) if gw_approval_id == approval_id and (not run_id or gw_run_id == run_id): local_gateway_approval_id = approval_id elif not run_id and found_target and pending: @@ -26354,8 +26351,10 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str, run_id: st local_gateway_resolved, _head, _total = resolve_gateway_pending_local( sid, local_gateway_approval_id, choice ) - elif approval_id and found_target and run_id and gateway_head_matches_target: - gateway_resolved = resolve_gateway_approval(sid, choice, resolve_all=False) or 0 + elif approval_id and found_target and run_id: + gateway_resolved, _head, _total = resolve_gateway_pending_run( + sid, approval_id, run_id, choice + ) elif not approval_id: gateway_resolved = resolve_gateway_approval(sid, choice, resolve_all=False) or 0 # Keep the historical no-id response path truthy for old clients/tests while diff --git a/tests/test_approval_unblock.py b/tests/test_approval_unblock.py index 7369baf2..b14b024e 100644 --- a/tests/test_approval_unblock.py +++ b/tests/test_approval_unblock.py @@ -601,6 +601,85 @@ class TestApprovalHTTPEndpoints: r._gateway_queues.pop(sid, None) _STREAM_RUN_IDS.pop(stream_id, None) + def test_identity_v1_non_head_run_approval_settles_exact_producer(self, monkeypatch): + """A relayed non-head run approval must wake only its own parked waiter.""" + from api import route_approvals as ra + from api import routes as r + + sid = f"identity-v1-non-head-{uuid.uuid4().hex[:8]}" + run_id = f"run-identity-v1-{uuid.uuid4().hex[:8]}" + head = { + "approval_id": "head-approval", + "run_id": run_id, + "command": "head command", + "_gateway_agent_identity_v1": True, + } + target = { + "approval_id": "target-approval", + "run_id": run_id, + "command": "target command", + "_gateway_agent_identity_v1": True, + } + head_entry = _ApprovalEntry(dict(head)) + target_entry = _ApprovalEntry(dict(target)) + captured = {} + relays = [] + + def fake_j(_handler, data, status=200, extra_headers=None): + captured.update(payload=data, status=status) + return data + + def fake_respond(_self, got_run_id, got_approval_id, choice): + relays.append((got_run_id, got_approval_id, choice)) + return {"resolved": 1} + + monkeypatch.setattr(r, "j", fake_j) + monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond) + monkeypatch.setattr( + "api.config.gateway_supports_approval_identity_v1", lambda *_args: True + ) + with _lock: + r._pending.pop(sid, None) + r._gateway_queues[sid] = [head_entry, target_entry] + try: + ra.submit_gateway_pending_mirror(sid, dict(target_entry.data)) + mirror = ra.gateway_pending_mirror( + sid, approval_id=target["approval_id"], run_id=run_id + ) + assert mirror is not None + + r._handle_approval_respond( + object(), + { + "session_id": sid, + "choice": "deny", + "approval_id": target["approval_id"], + "run_id": run_id, + "mirror_token": mirror[ra._GATEWAY_MIRROR_TOKEN], + }, + ) + + assert captured == { + "payload": {"ok": True, "choice": "deny", "relayed": True}, + "status": 200, + } + assert relays == [(run_id, target["approval_id"], "deny")] + assert target_entry.event.is_set() + assert target_entry.result == "deny" + assert not head_entry.event.is_set() + with _lock: + assert r._gateway_queues[sid] == [head_entry] + for _ in range(2): + with _lock: + ra.reconcile_gateway_pending_mirror_locked(sid) + assert ra.gateway_pending_mirror( + sid, approval_id=target["approval_id"], run_id=run_id + ) is None + finally: + with _lock: + r._pending.pop(sid, None) + r._gateway_queues.pop(sid, None) + def test_gateway_mirror_without_run_id_with_one_producer_resolves_exactly(self, monkeypatch): """A no-run mirror retires only after its exact local producer resolves.""" from api import routes as r diff --git a/tests/test_gateway_approval_legacy_path.py b/tests/test_gateway_approval_legacy_path.py index 93ac703b..dd12d971 100644 --- a/tests/test_gateway_approval_legacy_path.py +++ b/tests/test_gateway_approval_legacy_path.py @@ -875,13 +875,13 @@ def test_legacy_approval_without_run_id_retires_locally(): _STREAM_RUN_IDS.pop(stream_id, None) -def test_deny_retires_run_producer_so_reconciliation_cannot_resurrect_mirror(): - """Deny must consume the producer as well as its WebUI mirror. +def test_deny_settles_run_producer_so_reconciliation_cannot_resurrect_mirror(): + """Deny must settle the producer as well as its WebUI mirror. Selection is the behaviour under test, so the queue carries more than the - one entry being retired: a sibling approval for the same run, and a producer - belonging to a different run. Retiring one approval must consume exactly - that producer and leave both others queued. + one entry being resolved: a sibling approval for the same run, and a producer + belonging to a different run. Resolving one approval must consume exactly + that producer, wake its waiter, and leave both others queued. """ from types import SimpleNamespace from api import route_approvals as ra @@ -925,12 +925,13 @@ def test_deny_retires_run_producer_so_reconciliation_cannot_resurrect_mirror(): mirror = ra.gateway_pending_mirror(sid, approval_id=approval_id, run_id=run_id) assert mirror is not None - assert ra.retire_gateway_pending_mirror( + resolved, _head, _total = ra.resolve_gateway_pending_run( sid, approval_id=approval_id, run_id=run_id, - mirror_token=mirror[ra._GATEWAY_MIRROR_TOKEN], + choice="deny", ) + assert resolved == 1 assert queued_before == [target, same_run_sibling, other_run], ( "the queue should have held all three producers before retirement" @@ -938,6 +939,8 @@ def test_deny_retires_run_producer_so_reconciliation_cannot_resurrect_mirror(): with ra._lock: remaining = list(ra._gateway_queues.get(sid) or []) assert target not in remaining, "the denied approval's producer must be consumed" + assert target.event.is_set(), "the denied approval's waiter must be woken" + assert target.result == "deny" assert same_run_sibling in remaining, ( "a sibling approval on the same run must not be consumed by retiring another approval" ) From 5dd9afd73fab43c34fba66b1919fd28e372dcc83 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 09:45:24 +0300 Subject: [PATCH 12/47] test(approval): cover the response route's exact producer settlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route-level regression for a denied non-head run approval lives in tests/test_approval_unblock.py, which imports tools.approval._ApprovalEntry at module scope. That file skips wholesale when the installed agent no longer exposes the facade — the approval-module split dropped it — and also skips in CI, where no agent is installed at all. Nothing covered the wiring this fix changes. Only the response route supplies a target's (run_id, approval_id) to the legacy resolver, and that pair is what reaches the exact-run resolver; a regression there drops the producer without waking its waiter, leaving the agent thread parked until the approval timeout. Add the route-level regression to tests/test_gateway_approval_legacy_path.py, which runs wherever the suite runs. It answers a non-head approval through _handle_approval_respond with the payload the UI sends, with the run's head, a same-run sibling, and a producer from an unrelated run also queued. It asserts the target's producer is consumed AND its waiter woken, that the three survivors stay queued and unsignaled, and that two reconciliation passes do not rebuild the denied mirror. Producers are the agent's real _ApprovalEntry where that is importable and an equivalent stand-in where it is not, so the assertions mean the same thing in both shapes. Fails on unmodified master with the agent present and absent; passes with the fix. Mutation-checked by resolving every same-run producer instead of the exact one — the survivor assertions catch it. Additive commit: the earlier commits are published, so published history is preserved rather than rewritten. --- tests/test_gateway_approval_legacy_path.py | 143 +++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/tests/test_gateway_approval_legacy_path.py b/tests/test_gateway_approval_legacy_path.py index dd12d971..a2b0d74c 100644 --- a/tests/test_gateway_approval_legacy_path.py +++ b/tests/test_gateway_approval_legacy_path.py @@ -875,6 +875,149 @@ def test_legacy_approval_without_run_id_retires_locally(): _STREAM_RUN_IDS.pop(stream_id, None) +def test_route_deny_settles_exact_non_head_run_producer(): + """The response route must settle the exact producer whose card was denied. + + The route is the only caller that supplies a target's ``(run_id, + approval_id)`` to the legacy resolver, so a regression in that wiring is + invisible to helper-level coverage: the producer would be dropped from the + queue without its waiter ever being woken, leaving the agent thread parked + until the approval timeout. + + Unlike the route-level test in ``test_approval_unblock.py`` (which needs the + installed agent's ``tools.approval`` and is skipped without it), this one + runs wherever the suite runs. + """ + from types import SimpleNamespace + from api import route_approvals as ra + from api import routes as r + + sid = "sess-route-non-head-deny" + run_id = "run-route-non-head-deny" + head_id = "approval-route-head" + target_id = "approval-route-target" + sibling_id = "approval-route-sibling" + other_run_id = "run-route-unrelated" + head = { + "approval_id": head_id, + "run_id": run_id, + "command": "echo head", + "description": "Head approval on the run", + "_gateway_agent_identity_v1": True, + } + sibling = { + "approval_id": sibling_id, + "run_id": run_id, + "command": "echo sibling", + "description": "Non-head sibling approval on the same run", + "_gateway_agent_identity_v1": True, + } + target = { + "approval_id": target_id, + "run_id": run_id, + "command": "echo target", + "description": "Non-head approval on the same run", + "_gateway_agent_identity_v1": True, + } + other_run = { + "approval_id": "approval-route-other", + "run_id": other_run_id, + "command": "echo else", + "description": "Approval on an unrelated run", + } + + def producer(payload): + """The agent's real producer entry when installed, an equivalent one otherwise. + + The suite runs without hermes-agent in CI, where + ``tools.approval._ApprovalEntry`` does not exist; the stand-in carries the + same ``data`` / ``event`` / ``result`` / ``reason`` contract the resolution + path touches, so the assertions below mean the same thing in both shapes. + """ + try: + from tools.approval import _ApprovalEntry + except Exception: + _ApprovalEntry = None + if _ApprovalEntry is not None: + return _ApprovalEntry(dict(payload)) + return SimpleNamespace( + data=dict(payload), event=threading.Event(), result=None, reason=None + ) + + head_entry = producer(head) + sibling_entry = producer(sibling) + target_entry = producer(target) + other_run_entry = producer(other_run) + relayed = [] + captured = {} + + def fake_j(_handler, data, status=200, extra_headers=None): + captured.update(payload=data, status=status) + return data + + def fake_respond(_self, got_run_id, got_approval_id, choice): + relayed.append((got_run_id, got_approval_id, choice)) + return {"resolved": 1} + + try: + with ra._lock: + ra._pending.pop(sid, None) + ra._gateway_queues[sid] = [head_entry, sibling_entry, target_entry, other_run_entry] + ra.submit_gateway_pending_mirror(sid, dict(target_entry.data)) + mirror = ra.gateway_pending_mirror(sid, approval_id=target_id, run_id=run_id) + assert mirror is not None, "the non-head approval must be mirrored before it is answered" + + with patch("api.routes.j", new=fake_j), \ + patch("api.runner_client.HttpRunnerClient.respond_approval", new=fake_respond), \ + patch("api.config.gateway_supports_approval_identity_v1", return_value=True): + r._handle_approval_respond( + object(), + { + "session_id": sid, + "choice": "deny", + "approval_id": target_id, + "run_id": run_id, + "mirror_token": mirror[ra._GATEWAY_MIRROR_TOKEN], + }, + ) + + assert captured == { + "payload": {"ok": True, "choice": "deny", "relayed": True}, + "status": 200, + } + assert relayed == [(run_id, target_id, "deny")] + + assert target_entry.event.is_set(), "the denied approval's waiter must be woken" + assert target_entry.result == "deny" + with ra._lock: + remaining = list(ra._gateway_queues.get(sid) or []) + assert target_entry not in remaining, "the denied approval's producer must be consumed" + assert head_entry in remaining, ( + "the run's own head approval must not be consumed by answering another approval" + ) + assert not head_entry.event.is_set() + assert sibling_entry in remaining, ( + "a sibling approval on the same run must not be consumed" + ) + assert not sibling_entry.event.is_set() + assert other_run_entry in remaining, ( + "an unrelated run's producer must not be consumed" + ) + assert not other_run_entry.event.is_set() + + # These are the observations made by subsequent HTTP polling. + for _ in range(2): + with ra._lock: + ra.reconcile_gateway_pending_mirror_locked(sid) + assert ra.gateway_pending_mirror( + sid, approval_id=target_id, run_id=run_id + ) is None, "reconciliation must not resurrect the denied approval's mirror" + finally: + with ra._lock: + ra._pending.pop(sid, None) + ra._gateway_queues.pop(sid, None) + + def test_deny_settles_run_producer_so_reconciliation_cannot_resurrect_mirror(): """Deny must settle the producer as well as its WebUI mirror. From fdd72e815a7ee719bf8bc47eb9cd26d62045a4b4 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 16:49:57 +0300 Subject: [PATCH 13/47] Fix gateway approval settlement lifecycle Settle removed approval producers before fallible projection work, and fail-close every producer owned by a terminal gateway run across completion, failure, cancellation, and teardown while preserving unrelated runs. --- api/gateway_chat.py | 34 ++- api/route_approvals.py | 111 ++++++---- api/routes.py | 7 +- tests/test_gateway_approval_legacy_path.py | 108 ++++++++++ tests/test_gateway_approval_runs_api.py | 233 ++++++++++++++++++++- 5 files changed, 437 insertions(+), 56 deletions(-) diff --git a/api/gateway_chat.py b/api/gateway_chat.py index 0807af48..c66175d5 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -741,8 +741,12 @@ def _run_gateway_runs_api_streaming( sse_event = "message" continue if payload_event == "run.completed": - from api.route_approvals import retire_gateway_pending_mirror - retire_gateway_pending_mirror(session_id, run_id=run_id) + from api.route_approvals import settle_gateway_pending_run + settle_gateway_pending_run( + session_id, + run_id, + reason="Gateway run completed before approval resolution", + ) if payload.get("error"): raise RuntimeError(str(payload["error"])) output = str(payload.get("output") or "") @@ -754,12 +758,20 @@ def _run_gateway_runs_api_streaming( sse_event = "message" continue if payload_event == "run.failed": - from api.route_approvals import retire_gateway_pending_mirror - retire_gateway_pending_mirror(session_id, run_id=run_id) + from api.route_approvals import settle_gateway_pending_run + settle_gateway_pending_run( + session_id, + run_id, + reason="Gateway run failed before approval resolution", + ) raise RuntimeError(str(payload.get("error") or "Gateway run failed")) if payload_event == "run.cancelled": - from api.route_approvals import retire_gateway_pending_mirror - retire_gateway_pending_mirror(session_id, run_id=run_id) + from api.route_approvals import settle_gateway_pending_run + settle_gateway_pending_run( + session_id, + run_id, + reason="Gateway run was cancelled before approval resolution", + ) put_gateway_event("cancel", {"message": "Cancelled by gateway"}) return None, usage reasoning_delta = _gateway_sse_reasoning_delta(payload) @@ -1467,10 +1479,14 @@ def _run_gateway_chat_streaming( mapped_run_id = str(_STREAM_RUN_IDS.get(stream_id) or "").strip() if mapped_run_id: try: - from api.route_approvals import retire_gateway_pending_mirror - retire_gateway_pending_mirror(session_id, run_id=mapped_run_id) + from api.route_approvals import settle_gateway_pending_run + settle_gateway_pending_run( + session_id, + mapped_run_id, + reason="Gateway run ended during teardown before approval resolution", + ) except Exception: - logger.debug("Failed to retire gateway pending mirrors during teardown", exc_info=True) + logger.debug("Failed to settle gateway pending approvals during teardown", exc_info=True) if s is not None: try: with _get_session_agent_lock(session_id): diff --git a/api/route_approvals.py b/api/route_approvals.py index d956ab27..da3a27d9 100644 --- a/api/route_approvals.py +++ b/api/route_approvals.py @@ -10,13 +10,16 @@ Gateway approvals carry two pieces of state with one lifecycle: * the **mirror** entry in ``_pending`` — this module's projection of that producer for the UI. -A producer is consumed only by an explicit resolution operation, which assigns -its result and signals its waiter before reconciliation can observe it again. +A producer is consumed only by an explicit settlement operation, which assigns +its result and signals its waiter before fallible projection work begins. ``retire_gateway_pending_mirror`` cleans up the UI projection; it must not silently delete a producer, because that would strand the agent thread parked on the producer's event. For a provider-qualified response, ``resolve_gateway_pending_run`` matches the exact ``(run_id, approval_id)`` pair, settles that producer, and preserves same-run siblings and other runs. +When a run becomes terminal, ``settle_gateway_pending_run`` fail-closes every +producer owned by that run with ``deny`` before retiring its mirrors; producers +owned by another run remain live. """ import queue import threading @@ -520,19 +523,26 @@ def release_gateway_approval_relay_owner(session_key: str, run_id: str, approval _gateway_relay_owners.pop(key, None) +def _settle_gateway_entry(entry, choice: str, reason: str | None = None) -> None: + """Complete a removed producer before any fallible projection work.""" + entry.result = choice + if reason: + entry.reason = reason + entry.event.set() + + def retire_gateway_pending_mirror( session_key: str, approval_id: str = "", run_id: str = "", mirror_token: str = "", ) -> bool: - """Retire one approval, or every mirror for a terminal run.""" + """Retire one approval mirror, or every mirror for a terminal run.""" with _lock: reconcile_gateway_pending_mirror_locked(session_key) queue = _pending.get(session_key) entries = queue if isinstance(queue, list) else [queue] if queue else [] normalized_run_id = str(run_id or "").strip() - gateway_queue_changed = False if approval_id: match = _gateway_pending_mirror_locked( session_key, @@ -555,20 +565,7 @@ def retire_gateway_pending_mirror( if _is_gateway_mirror_entry(entry) and not str(entry.get("run_id") or "").strip() ] - if normalized_run_id: - gateway_queue = _gateway_queues.get(session_key) or [] - retained_gateway_queue = [] - for entry in gateway_queue: - data = getattr(entry, "data", None) or {} - if str(data.get("run_id") or "").strip() == normalized_run_id: - continue - retained_gateway_queue.append(entry) - gateway_queue_changed = len(retained_gateway_queue) != len(gateway_queue) - if retained_gateway_queue: - _gateway_queues[session_key] = retained_gateway_queue - else: - _gateway_queues.pop(session_key, None) - if not retired and not gateway_queue_changed: + if not retired: head, total, changed = reconcile_gateway_pending_mirror_locked(session_key) _approval_sse_notify_locked(session_key, head, total) if changed: @@ -586,6 +583,60 @@ def retire_gateway_pending_mirror( return True +def settle_gateway_pending_run( + session_key: str, + run_id: str, + *, + reason: str, +) -> tuple[int, dict | None, int]: + """Fail-close every producer and mirror owned by a terminal gateway run.""" + run_id = str(run_id or "").strip() + if not run_id: + return 0, None, 0 + + targets = [] + with _lock: + gateway_queue = _gateway_queues.get(session_key) or [] + retained_gateway_queue = [] + for entry in gateway_queue: + data = getattr(entry, "data", None) or {} + if str(data.get("run_id") or "").strip() == run_id: + targets.append(entry) + else: + retained_gateway_queue.append(entry) + if retained_gateway_queue: + _gateway_queues[session_key] = retained_gateway_queue + else: + _gateway_queues.pop(session_key, None) + + # Removal and waiter settlement are one semantic operation. In + # particular, reconciliation and SSE notification below may raise. + for entry in targets: + _settle_gateway_entry(entry, "deny", reason) + + queue = _pending.get(session_key) + entries = queue if isinstance(queue, list) else [queue] if queue else [] + retained_pending = [ + entry + for entry in entries + if not ( + _is_gateway_mirror_entry(entry) + and str(entry.get("run_id") or "").strip() == run_id + ) + ] + mirrors_retired = len(retained_pending) != len(entries) + if retained_pending: + _pending[session_key] = retained_pending + else: + _pending.pop(session_key, None) + + head, total, changed = reconcile_gateway_pending_mirror_locked(session_key) + _approval_sse_notify_locked(session_key, head, total) + if targets or mirrors_retired or changed: + publish_session_list_changed("attention_resolved") + return len(targets), head, total + + def _gateway_mirrored_pending_run_id(session_key: str, approval_id: str) -> str | None: """Compatibility wrapper for exact run-backed lookup.""" approval_id = str(approval_id or "").strip() @@ -817,14 +868,12 @@ def resolve_gateway_pending_local( _gateway_queues[session_key] = gateway_queue else: _gateway_queues.pop(session_key, None) + if target is not None: + _settle_gateway_entry(target, choice, reason) head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key) _approval_sse_notify_locked(session_key, head, total) if target is None: return 0, head, total - target.result = choice - if reason: - target.reason = reason - target.event.set() publish_session_list_changed("attention_resolved") return 1, head, total @@ -857,14 +906,12 @@ def resolve_gateway_pending_run( _gateway_queues[session_key] = retained_gateway_queue else: _gateway_queues.pop(session_key, None) + if target is not None: + _settle_gateway_entry(target, choice, reason) head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key) _approval_sse_notify_locked(session_key, head, total) if target is None: return 0, head, total - target.result = choice - if reason: - target.reason = reason - target.event.set() publish_session_list_changed("attention_resolved") return 1, head, total @@ -909,12 +956,9 @@ def resolve_gateway_pending_local_no_run_mirror( _pending[session_key] = entries else: _pending.pop(session_key, None) + _settle_gateway_entry(target, choice, reason) head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key) _approval_sse_notify_locked(session_key, head, total) - target.result = choice - if reason: - target.reason = reason - target.event.set() publish_session_list_changed("attention_resolved") return True, 1, head, total @@ -957,14 +1001,11 @@ def resolve_gateway_pending_local_all( else: _pending.pop(session_key, None) + for entry in targets: + _settle_gateway_entry(entry, choice, reason) head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key) _approval_sse_notify_locked(session_key, head, total) - for entry in targets: - entry.result = choice - if reason: - entry.reason = reason - entry.event.set() if targets or removed_pending: publish_session_list_changed("attention_resolved") return len(targets), head, total diff --git a/api/routes.py b/api/routes.py index 883dbbd7..6afd81ad 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10771,6 +10771,7 @@ from api.route_approvals import ( # noqa: F401 — re-exports for backward comp gateway_pending_mirrors, release_gateway_approval_relay_owner, retire_gateway_pending_mirror, + settle_gateway_pending_run, reconcile_gateway_pending_mirror_locked, resolve_gateway_pending_local, resolve_gateway_pending_run, @@ -14485,7 +14486,11 @@ def handle_get(handler, parsed) -> bool: if stop_gateway_run(run_id): owner_sid = stream_owner_session_id(stream_id) if owner_sid: - retire_gateway_pending_mirror(owner_sid, run_id=run_id) + settle_gateway_pending_run( + owner_sid, + run_id, + reason="Gateway run was cancelled before approval resolution", + ) else: gateway_stop_blocked = True except Exception: diff --git a/tests/test_gateway_approval_legacy_path.py b/tests/test_gateway_approval_legacy_path.py index a2b0d74c..20169d19 100644 --- a/tests/test_gateway_approval_legacy_path.py +++ b/tests/test_gateway_approval_legacy_path.py @@ -10,6 +10,8 @@ import threading from pathlib import Path from unittest.mock import MagicMock, patch +import pytest + from api.gateway_chat import _gateway_runs_approval_event REPO_ROOT = Path(__file__).parent.parent @@ -1110,3 +1112,109 @@ def test_deny_settles_run_producer_so_reconciliation_cannot_resurrect_mirror(): with ra._lock: ra._pending.pop(sid, None) ra._gateway_queues.pop(sid, None) + + +@pytest.mark.parametrize( + "failure_point", + ["reconcile_gateway_pending_mirror_locked", "_approval_sse_notify_locked"], +) +def test_exact_run_resolution_settles_before_projection_failure(failure_point): + """Projection failures must not strand an already-consumed producer.""" + from types import SimpleNamespace + from api import route_approvals as ra + + sid = f"sess-exact-projection-failure-{failure_point}" + run_id = "run-exact-projection-failure" + target = SimpleNamespace( + data={"run_id": run_id, "approval_id": "approval-target"}, + event=threading.Event(), + result=None, + reason=None, + ) + sibling = SimpleNamespace( + data={"run_id": run_id, "approval_id": "approval-sibling"}, + event=threading.Event(), + result=None, + reason=None, + ) + try: + with ra._lock: + ra._pending.pop(sid, None) + ra._gateway_queues[sid] = [target, sibling] + + with patch.object(ra, failure_point, side_effect=RuntimeError("projection failed")): + with pytest.raises(RuntimeError, match="projection failed"): + ra.resolve_gateway_pending_run( + sid, + approval_id="approval-target", + run_id=run_id, + choice="deny", + reason="user denied", + ) + + assert target.result == "deny" + assert target.reason == "user denied" + assert target.event.is_set(), "the consumed producer must be woken before projection work" + assert sibling.result is None + assert sibling.reason is None + assert not sibling.event.is_set() + with ra._lock: + assert ra._gateway_queues[sid] == [sibling] + finally: + with ra._lock: + ra._pending.pop(sid, None) + ra._gateway_queues.pop(sid, None) + + +@pytest.mark.parametrize( + "terminal_reason", + [ + "Gateway run completed before approval resolution", + "Gateway run failed before approval resolution", + "Gateway run was cancelled before approval resolution", + "Gateway run ended during teardown before approval resolution", + ], +) +def test_terminal_run_settlement_denies_all_run_producers_and_preserves_other_runs( + terminal_reason, +): + """Every terminal exit fails closed without consuming another run's producer.""" + from types import SimpleNamespace + from api import route_approvals as ra + + sid = f"sess-terminal-run-{terminal_reason.split()[2]}" + run_id = "run-terminal" + + def producer(entry_run_id, approval_id): + return SimpleNamespace( + data={"run_id": entry_run_id, "approval_id": approval_id}, + event=threading.Event(), + result=None, + reason=None, + ) + + targets = [producer(run_id, "approval-a"), producer(run_id, "approval-b")] + survivor = producer("run-other", "approval-other") + try: + with ra._lock: + ra._pending.pop(sid, None) + ra._gateway_queues[sid] = [targets[0], survivor, targets[1]] + + settled, _head, _total = ra.settle_gateway_pending_run( + sid, run_id, reason=terminal_reason + ) + + assert settled == 2 + for target in targets: + assert target.result == "deny" + assert target.reason == terminal_reason + assert target.event.is_set() + assert survivor.result is None + assert survivor.reason is None + assert not survivor.event.is_set() + with ra._lock: + assert ra._gateway_queues[sid] == [survivor] + finally: + with ra._lock: + ra._pending.pop(sid, None) + ra._gateway_queues.pop(sid, None) diff --git a/tests/test_gateway_approval_runs_api.py b/tests/test_gateway_approval_runs_api.py index 7635aefb..51022542 100644 --- a/tests/test_gateway_approval_runs_api.py +++ b/tests/test_gateway_approval_runs_api.py @@ -333,6 +333,178 @@ def test_gateway_runs_api_submission(): assert captured["body_extras"]["reasoning_effort"] == "high" +@pytest.mark.parametrize( + ("terminal_event", "terminal_reason"), + [ + ("run.completed", "Gateway run completed before approval resolution"), + ("run.failed", "Gateway run failed before approval resolution"), + ("run.cancelled", "Gateway run was cancelled before approval resolution"), + ], +) +def test_gateway_terminal_event_fail_closes_parked_run_producer( + terminal_event, + terminal_reason, +): + """Each terminal gateway event denies and wakes the run's parked producers.""" + from api import gateway_chat + from api import route_approvals as approvals + + sid = f"sess-terminal-event-{terminal_event}" + stream_id = f"stream-terminal-event-{terminal_event}" + run_id = f"run-terminal-event-{terminal_event}" + target = SimpleNamespace( + data={"run_id": run_id, "approval_id": "approval-target"}, + event=threading.Event(), + result=None, + reason=None, + ) + survivor = SimpleNamespace( + data={"run_id": "run-other", "approval_id": "approval-other"}, + event=threading.Event(), + result=None, + reason=None, + ) + + class JsonResponse: + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def read(self, _limit=None): + return json.dumps({"run_id": run_id}).encode() + + class SseResponse: + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def __iter__(self): + payload = {"event": terminal_event} + if terminal_event == "run.failed": + payload["error"] = "terminal failure" + return iter([f"data: {json.dumps(payload)}".encode(), b""]) + + def fake_urlopen(req, *, timeout=None): + del timeout + return JsonResponse() if req.full_url.endswith("/v1/runs") else SseResponse() + + try: + with approvals._lock: + approvals._pending.pop(sid, None) + approvals._gateway_queues[sid] = [target, survivor] + with patch("urllib.request.urlopen", side_effect=fake_urlopen): + if terminal_event == "run.failed": + with pytest.raises(RuntimeError, match="terminal failure"): + gateway_chat._run_gateway_runs_api_streaming( + sid, "hi", "test", "/tmp", stream_id, "http://gw:8642", "", [], {}, + put_gateway_event=lambda *_args: None, + cancel_event=threading.Event(), + session=SimpleNamespace(context_messages=[]), + ) + else: + gateway_chat._run_gateway_runs_api_streaming( + sid, "hi", "test", "/tmp", stream_id, "http://gw:8642", "", [], {}, + put_gateway_event=lambda *_args: None, + cancel_event=threading.Event(), + session=SimpleNamespace(context_messages=[]), + ) + + assert target.result == "deny" + assert target.reason == terminal_reason + assert target.event.is_set() + assert survivor.result is None + assert not survivor.event.is_set() + with approvals._lock: + assert approvals._gateway_queues[sid] == [survivor] + finally: + gateway_chat._STREAM_RUN_IDS.pop(stream_id, None) + with approvals._lock: + approvals._pending.pop(sid, None) + approvals._gateway_queues.pop(sid, None) + + +def test_gateway_stream_teardown_fail_closes_parked_run_producer(): + """A disconnect before a terminal event still settles the mapped run.""" + from api import gateway_chat + from api import route_approvals as approvals + from api.config import STREAMS, STREAMS_LOCK + + sid = "sess-terminal-teardown" + stream_id = "stream-terminal-teardown" + run_id = "run-terminal-teardown" + target = SimpleNamespace( + data={"run_id": run_id, "approval_id": "approval-target"}, + event=threading.Event(), + result=None, + reason=None, + ) + survivor = SimpleNamespace( + data={"run_id": "run-other", "approval_id": "approval-other"}, + event=threading.Event(), + result=None, + reason=None, + ) + events = [] + queue = MagicMock() + queue.put_nowait = lambda item: events.append(item) + with STREAMS_LOCK: + STREAMS[stream_id] = queue + + session = MagicMock() + session.active_stream_id = stream_id + session.workspace = "/tmp" + session.model = "test" + session.model_provider = None + session.profile = None + session.context_messages = [] + session.messages = [] + session.pending_user_message = None + session.pending_attachments = None + session.pending_started_at = None + + def disconnect_after_run_id(*_args, **_kwargs): + gateway_chat._publish_gateway_run_id(stream_id, run_id) + raise RuntimeError("gateway disconnected") + + try: + with approvals._lock: + approvals._pending.pop(sid, None) + approvals._gateway_queues[sid] = [target, survivor] + with patch.dict( + "os.environ", + {"HERMES_WEBUI_CHAT_BACKEND": "gateway", "HERMES_WEBUI_GATEWAY_USE_RUNS_API": "1"}, + ), patch("api.gateway_chat.gateway_supports_approval", return_value=True), patch( + "api.gateway_chat._run_gateway_runs_api_streaming", + side_effect=disconnect_after_run_id, + ), patch("api.gateway_chat.get_session", return_value=session): + gateway_chat._run_gateway_chat_streaming( + session_id=sid, + msg_text="hi", + model="test-model", + workspace="/tmp", + stream_id=stream_id, + ) + + assert target.result == "deny" + assert target.reason == "Gateway run ended during teardown before approval resolution" + assert target.event.is_set() + assert survivor.result is None + assert not survivor.event.is_set() + with approvals._lock: + assert approvals._gateway_queues[sid] == [survivor] + finally: + gateway_chat._STREAM_RUN_IDS.pop(stream_id, None) + with STREAMS_LOCK: + STREAMS.pop(stream_id, None) + with approvals._lock: + approvals._pending.pop(sid, None) + approvals._gateway_queues.pop(sid, None) + + # --------------------------------------------------------------------------- # 3. Approval event translation # --------------------------------------------------------------------------- @@ -1268,22 +1440,32 @@ def test_terminal_run_retirement_removes_all_same_run_mirrors(): approvals._pending.pop(sid, None) -def test_terminal_run_retirement_clears_same_run_gateway_queue_state(): +def test_terminal_run_settlement_clears_same_run_gateway_queue_state(): import api.route_approvals as approvals sid = "sess-terminal-run-queue" approvals._pending.pop(sid, None) approvals._gateway_queues.pop(sid, None) try: - approvals._gateway_queues[sid] = [ - SimpleNamespace(data={"run_id": "run-a", "approval_id": "appr-a", "command": "a"}), - SimpleNamespace(data={"command": "local-head"}), - ] + target = SimpleNamespace( + data={"run_id": "run-a", "approval_id": "appr-a", "command": "a"}, + event=threading.Event(), + result=None, + reason=None, + ) + local_head = SimpleNamespace(data={"command": "local-head"}) + approvals._gateway_queues[sid] = [target, local_head] approvals.submit_gateway_pending_mirror( sid, {"run_id": "run-a", "approval_id": "appr-a", "command": "a"}, ) - assert approvals.retire_gateway_pending_mirror(sid, run_id="run-a") + settled, _head, _total = approvals.settle_gateway_pending_run( + sid, "run-a", reason="terminal" + ) + assert settled == 1 + assert target.result == "deny" + assert target.reason == "terminal" + assert target.event.is_set() assert approvals.gateway_pending_mirror(sid, run_id="run-a") is None assert not any( str((getattr(entry, "data", None) or {}).get("run_id") or "").strip() == "run-a" @@ -1294,18 +1476,30 @@ def test_terminal_run_retirement_clears_same_run_gateway_queue_state(): approvals._gateway_queues.pop(sid, None) -def test_terminal_run_retirement_clears_non_head_same_run_gateway_queue_state(): +def test_terminal_run_settlement_clears_non_head_same_run_gateway_queue_state(): import api.route_approvals as approvals sid = "sess-terminal-run-non-head-queue" approvals._pending.pop(sid, None) approvals._gateway_queues.pop(sid, None) try: + target = SimpleNamespace( + data={"run_id": "run-a", "approval_id": "appr-a", "command": "a"}, + event=threading.Event(), + result=None, + reason=None, + ) approvals._gateway_queues[sid] = [ SimpleNamespace(data={"command": "local-head"}), - SimpleNamespace(data={"run_id": "run-a", "approval_id": "appr-a", "command": "a"}), + target, ] - assert approvals.retire_gateway_pending_mirror(sid, run_id="run-a") + settled, _head, _total = approvals.settle_gateway_pending_run( + sid, "run-a", reason="terminal" + ) + assert settled == 1 + assert target.result == "deny" + assert target.reason == "terminal" + assert target.event.is_set() assert approvals.gateway_pending_mirror(sid, run_id="run-a") is None assert not any( str((getattr(entry, "data", None) or {}).get("run_id") or "").strip() == "run-a" @@ -1865,7 +2059,15 @@ def test_chat_cancel_waits_for_worker_published_run_id_before_settlement( STREAMS[stream_id] = SimpleNamespace(put_nowait=lambda *_args, **_kwargs: None) register_stream_owner(stream_id, sid) approvals._pending.pop(sid, None) - approvals.submit_gateway_pending_mirror(sid, {"run_id": "run-stop/1", "command": "first"}) + approval_entry = SimpleNamespace( + data={"run_id": "run-stop/1", "approval_id": "approval-stop"}, + event=threading.Event(), + result=None, + reason=None, + ) + with approvals._lock: + approvals._gateway_queues[sid] = [approval_entry] + approvals.submit_gateway_pending_mirror(sid, approval_entry.data) session = SimpleNamespace( profile=None, workspace="/tmp", @@ -1945,8 +2147,15 @@ def test_chat_cancel_waits_for_worker_published_run_id_before_settlement( assert called["stop"] == "run-stop/1" assert called["cancel"] is expect_cancel if stop_result: + assert approval_entry.result == "deny" + assert approval_entry.reason == ( + "Gateway run was cancelled before approval resolution" + ) + assert approval_entry.event.is_set() assert approvals.gateway_pending_mirror(sid, run_id="run-stop/1") is None else: + assert approval_entry.result is None + assert not approval_entry.event.is_set() assert stream_id in STREAMS assert stream_owner_session_id(stream_id) == sid assert session.active_stream_id == stream_id @@ -1957,7 +2166,9 @@ def test_chat_cancel_waits_for_worker_published_run_id_before_settlement( release_worker.set() request_thread.join(timeout=5) worker_thread.join(timeout=5) - approvals._pending.pop(sid, None) + with approvals._lock: + approvals._pending.pop(sid, None) + approvals._gateway_queues.pop(sid, None) STREAMS.pop(stream_id, None) ACTIVE_RUNS.pop(stream_id, None) unregister_stream_owner(stream_id) From d7ab08ce81eb3089663e88c26b2a559f7bec06d5 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 22 Sep 2026 06:21:34 +0000 Subject: [PATCH 14/47] docs(changelog): stamp #7570 gateway approval deny retirement --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db93647..b455fe75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ ### Fixed +- **Denying a gateway approval now retires the request instead of leaving the run waiting.** + When an approval routed through the gateway was denied, the deny reached the agent but the + local producer was never retired, so the run could sit waiting on a decision that had already + been made. Denial now settles the producer atomically, scoped to the exact + `(session_id, run_id, approval_id)` that was answered — sibling approvals from the same run + and approvals from other runs are left untouched — and repeated or late responses are bounded + no-ops rather than resurrecting a settled request. Thanks @snoyberg. (#7570) + - **The approval card's "Skip all this session" button no longer shows two lightning bolts.** The button rendered its ⚡ twice — once from the icon span in the markup and again from the translated label, which carried its own leading glyph in 14 of 15 locales. The icon now comes From 4e2704177096fa2819b00fd3e97bdb9c821a87b7 Mon Sep 17 00:00:00 2001 From: shameez-struggles-to-commit Date: Fri, 21 Aug 2026 15:46:27 -0700 Subject: [PATCH 15/47] fix(models): probe the live Go catalog for OpenCode Go (#5311 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the #5611 stopgap that skipped the live /v1/models probe for OpenCode Go. Its premise — the probe returning public-catalog models not enabled on the Go tier — no longer holds: Hermes core v0.20.5 (fcbd1076) ships a Go-specific provider profile probing https://opencode.ai/zen/go/v1/models directly and merges results live-first with its curated Go list (#49129). WebUI again delegates like every other provider (#1240): live catalog first, curated static _PROVIDER_MODELS as offline fallback only. Runtime tests pin the behavior (sentinel live id must reach the Go group; probe failure falls back to static; providers.opencode-go.models allowlist still wins #644). --- api/config.py | 6 - .../test_issue5311_opencode_go_live_models.py | 167 ++++++++++++++++++ ...est_issue5311_opencode_go_static_models.py | 72 -------- 3 files changed, 167 insertions(+), 78 deletions(-) create mode 100644 tests/test_issue5311_opencode_go_live_models.py delete mode 100644 tests/test_issue5311_opencode_go_static_models.py diff --git a/api/config.py b/api/config.py index 979ce3f7..c77e65fc 100644 --- a/api/config.py +++ b/api/config.py @@ -9771,12 +9771,6 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa if not raw_models: if pid == "moa": raw_models = _moa_preset_models_from_config(cfg) - elif pid == "opencode-go": - # Skip live /v1/models probe for OpenCode Go — it - # returns models from the public catalog that are - # not enabled on the Go tier, causing 404 when - # selected. Use the curated static list only. (#5311) - pass else: raw_models = _models_from_live_provider_ids( pid, diff --git a/tests/test_issue5311_opencode_go_live_models.py b/tests/test_issue5311_opencode_go_live_models.py new file mode 100644 index 00000000..fccdb7f6 --- /dev/null +++ b/tests/test_issue5311_opencode_go_live_models.py @@ -0,0 +1,167 @@ +"""#5311 follow-up: OpenCode Go uses the live Hermes CLI catalog again. + +Background: #5611 made WebUI skip the live ``/v1/models`` probe for OpenCode Go +because the probe returned public-catalog models not enabled on the Go tier. +That premise no longer holds: Hermes core v0.20.5 (commit fcbd1076, tag +v2026.8.19) ships a Go-specific provider profile that probes +``https://opencode.ai/zen/go/v1/models`` directly and merges results +live-first with its curated Go list (hermes_cli/models.py, #49129). With the +correct Go-tier endpoint in core, WebUI should delegate like every other +provider (#1240): live catalog first, the curated static ``_PROVIDER_MODELS`` +list as offline fallback only. +""" + +from __future__ import annotations + +import sys +import types + +import api.config as config +import api.profiles as profiles + + +_PROVIDER_ENV_VARS = ( + "OPENCODE_ZEN_API_KEY", + "OPENCODE_GO_API_KEY", + "OPENCODE_API_KEY", +) + + +def _scrub_provider_env(monkeypatch): + for name in _PROVIDER_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +def _install_fake_hermes_cli(monkeypatch, *, provider_id: str, live_ids, raise_on_lookup: bool = False): + """Install a hermes_cli stub that reports one authenticated provider.""" + fake_pkg = types.ModuleType("hermes_cli") + fake_pkg.__path__ = [] + + fake_models = types.ModuleType("hermes_cli.models") + fake_models.list_available_providers = lambda: [ + {"id": provider_id, "authenticated": True} + ] + + calls: list[str] = [] + + def provider_model_ids(pid): + calls.append(pid) + if raise_on_lookup: + raise RuntimeError("simulated provider_model_ids failure") + return list(live_ids) if pid == provider_id else [] + + fake_models.provider_model_ids = provider_model_ids + + fake_auth = types.ModuleType("hermes_cli.auth") + + def get_auth_status(pid): + if pid == provider_id: + return {"logged_in": True, "key_source": ""} + return {"logged_in": False, "key_source": ""} + + fake_auth.get_auth_status = get_auth_status + + monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg) + monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models) + monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth) + monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False) + monkeypatch.delitem(sys.modules, "agent", raising=False) + config.invalidate_models_cache() + return calls + + +def _configure(monkeypatch, tmp_path, *, provider: str, providers: dict | None = None): + hermes_home = tmp_path / "hermes-home" + hermes_home.mkdir() + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: hermes_home) + monkeypatch.setattr(config, "_get_config_path", lambda: tmp_path / "missing-config.yaml") + monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") + monkeypatch.setattr( + config, + "cfg", + { + "model": {"provider": provider, "default": ""}, + "providers": providers if providers is not None else {}, + "fallback_providers": [], + }, + ) + monkeypatch.setattr(config, "_cfg_mtime", 0.0) + monkeypatch.setattr(config, "_cfg_path", config._get_config_path(), raising=False) + config.invalidate_models_cache() + + +def _provider_group(result: dict, provider_id: str) -> dict: + return next(g for g in result["groups"] if g.get("provider_id") == provider_id) + + +def _ids(group: dict) -> list[str]: + return [m.get("id") for m in group.get("models", [])] + + +def test_opencode_go_probes_live_catalog(monkeypatch, tmp_path): + """The Go picker group must come from the live Hermes CLI catalog. + + ``sentinel-go-model`` is intentionally absent from every static list. If + WebUI special-cases OpenCode Go away from the live probe again (the #5611 + stopgap), the sentinel disappears and this test fails. + """ + _scrub_provider_env(monkeypatch) + calls = _install_fake_hermes_cli( + monkeypatch, + provider_id="opencode-go", + live_ids=["sentinel-go-model", "kimi-k3"], + ) + _configure(monkeypatch, tmp_path, provider="opencode-go") + + result = config.get_available_models() + group = _provider_group(result, "opencode-go") + + assert calls == ["opencode-go"] + ids = _ids(group) + assert "sentinel-go-model" in ids + assert "kimi-k3" in ids + assert all(m.get("label") for m in group["models"]) + + +def test_opencode_go_static_fallback_when_probe_fails(monkeypatch, tmp_path): + """Offline / CLI failure must fall back to the curated static list.""" + _scrub_provider_env(monkeypatch) + calls = _install_fake_hermes_cli( + monkeypatch, + provider_id="opencode-go", + live_ids=[], + raise_on_lookup=True, + ) + _configure(monkeypatch, tmp_path, provider="opencode-go") + + result = config.get_available_models() + group = _provider_group(result, "opencode-go") + + assert calls == ["opencode-go"] + ids = _ids(group) + assert "sentinel-go-model" not in ids + assert "glm-5" in ids + assert "kimi-k2.5" in ids + + +def test_opencode_go_config_allowlist_still_wins(monkeypatch, tmp_path): + """An explicit ``providers.opencode-go.models`` allowlist remains the + local source of truth (#644) and is not bypassed by the live probe.""" + _scrub_provider_env(monkeypatch) + calls = _install_fake_hermes_cli( + monkeypatch, + provider_id="opencode-go", + live_ids=["sentinel-go-model"], + ) + _configure( + monkeypatch, + tmp_path, + provider="opencode-go", + providers={"opencode-go": {"models": ["glm-5"]}}, + ) + + result = config.get_available_models() + group = _provider_group(result, "opencode-go") + + assert calls == [] # probe never consulted + assert _ids(group) == ["glm-5"] diff --git a/tests/test_issue5311_opencode_go_static_models.py b/tests/test_issue5311_opencode_go_static_models.py deleted file mode 100644 index 7539619d..00000000 --- a/tests/test_issue5311_opencode_go_static_models.py +++ /dev/null @@ -1,72 +0,0 @@ -"""#5311 / #5611: OpenCode Go's generic live probe used to return public-catalog -models not enabled on the Go tier, so the picker intentionally falls back to a curated -static _PROVIDER_MODELS list. Keep that curated list in sync with the public Go docs -and documented Go models endpoint while excluding preview/free-only Zen models. -""" - -import ast -from pathlib import Path - -ROOT = Path(__file__).resolve().parent.parent -CONFIG_PATH = ROOT / "api" / "config.py" -CONFIG = CONFIG_PATH.read_text(encoding="utf-8") - -EXPECTED_OPENCODE_GO_MODEL_IDS = [ - "minimax-m3", - "minimax-m2.7", - "minimax-m2.5", - "kimi-k2.7-code", - "kimi-k2.6", - "kimi-k2.5", - "glm-5.2", - "glm-5.1", - "glm-5", - "deepseek-v4-pro", - "deepseek-v4-flash", - "qwen3.7-max", - "qwen3.7-plus", - "qwen3.6-plus", - "qwen3.5-plus", - "mimo-v2-pro", - "mimo-v2-omni", - "mimo-v2.5-pro", - "mimo-v2.5", -] - - -def _opencode_go_static_models(): - tree = ast.parse(CONFIG, filename=str(CONFIG_PATH)) - for node in tree.body: - if not isinstance(node, ast.Assign): - continue - if not any(isinstance(target, ast.Name) and target.id == "_PROVIDER_MODELS" for target in node.targets): - continue - provider_models = ast.literal_eval(node.value) - return provider_models["opencode-go"] - raise AssertionError("_PROVIDER_MODELS assignment not found") - - -def test_opencode_go_skips_live_models_probe(): - # The provider-loop must special-case opencode-go to skip the old generic - # live probe and fall through to this curated static list. - body = CONFIG[CONFIG.index("def get_available_models"):] - body = body[: body.index("\ndef ", 1)] - assert 'elif pid == "opencode-go":' in body - idx = body.index('elif pid == "opencode-go":') - branch = body[idx: idx + 400] - assert "_models_from_live_provider_ids" not in branch.split("else:")[0] - - -def test_opencode_go_static_models_match_documented_endpoint_snapshot(): - # Snapshot from https://opencode.ai/zen/go/v1/models on 2026-07-07. - # This catches stale-list regressions such as missing GLM-5.2 / MiniMax M3. - models = _opencode_go_static_models() - assert [model["id"] for model in models] == EXPECTED_OPENCODE_GO_MODEL_IDS - - -def test_opencode_go_recent_additions_have_human_labels(): - labels = {model["id"]: model["label"] for model in _opencode_go_static_models()} - assert labels["glm-5.2"] == "GLM-5.2" - assert labels["minimax-m3"] == "MiniMax M3" - assert labels["kimi-k2.7-code"] == "Kimi K2.7 Code" - assert labels["qwen3.7-max"] == "Qwen3.7 Max" From df345af9eb04dd7e1c166089823ce3c994cab66a Mon Sep 17 00:00:00 2001 From: shameez-struggles-to-commit Date: Fri, 21 Aug 2026 15:50:20 -0700 Subject: [PATCH 16/47] chore(models): sync opencode-go static fallback to core's curated list The WebUI fallback list mirrors Hermes core's curated opencode-go list (hermes_cli/models.py, synced 2026-08-20/21 against the live /zen/go/v1/models endpoint and the public Go docs). Core owns the sync duty; WebUI only mirrors. Adds the nine ids core gained since the 2026-07-07 snapshot (kimi-k3, gpt-5.6-luna, glm-5.3, grok-4.5, qwen3.8-max, hy3, hy3-preview, muse-spark-1.2-contributor, ox-alpha-free) and reorders to core's order so future diffs are mechanical. Snapshot test updated accordingly. --- api/config.py | 56 +++++++++----- .../test_issue5311_opencode_go_live_models.py | 77 +++++++++++++++++++ 2 files changed, 112 insertions(+), 21 deletions(-) diff --git a/api/config.py b/api/config.py index c77e65fc..b279e0e2 100644 --- a/api/config.py +++ b/api/config.py @@ -1904,28 +1904,42 @@ _PROVIDER_MODELS = { {"id": "big-pickle", "label": "Big Pickle"}, ], # OpenCode Go — flat-rate models via opencode.ai/go ($10/month). - # Synced 2026-07-08 from the public Go docs and documented models endpoint. - # Keep preview/free-only Zen models out of this Go picker snapshot. + # Fallback only: the live Hermes CLI catalog (Go-specific + # /zen/go/v1/models probe, core v0.20.5+) leads (#1240, #5311). + # Mirrors Hermes core's curated opencode-go list + # (hermes_cli/models.py, synced there against the live endpoint and + # https://opencode.ai/docs/go/ on 2026-08-20/21). `ox-alpha-free` is the + # Go-subscription twin of Zen's keyless Ox Alpha — same id shape, but it + # requires the Go key. "opencode-go": [ - {"id": "minimax-m3", "label": "MiniMax M3"}, - {"id": "minimax-m2.7", "label": "MiniMax M2.7"}, - {"id": "minimax-m2.5", "label": "MiniMax M2.5"}, - {"id": "kimi-k2.7-code", "label": "Kimi K2.7 Code"}, - {"id": "kimi-k2.6", "label": "Kimi K2.6"}, - {"id": "kimi-k2.5", "label": "Kimi K2.5"}, - {"id": "glm-5.2", "label": "GLM-5.2"}, - {"id": "glm-5.1", "label": "GLM-5.1"}, - {"id": "glm-5", "label": "GLM-5"}, - {"id": "deepseek-v4-pro", "label": "DeepSeek V4 Pro"}, - {"id": "deepseek-v4-flash","label": "DeepSeek V4 Flash"}, - {"id": "qwen3.7-max", "label": "Qwen3.7 Max"}, - {"id": "qwen3.7-plus", "label": "Qwen3.7 Plus"}, - {"id": "qwen3.6-plus", "label": "Qwen3.6 Plus"}, - {"id": "qwen3.5-plus", "label": "Qwen3.5 Plus"}, - {"id": "mimo-v2-pro", "label": "MiMo V2 Pro"}, - {"id": "mimo-v2-omni", "label": "MiMo V2 Omni"}, - {"id": "mimo-v2.5-pro", "label": "MiMo V2.5 Pro"}, - {"id": "mimo-v2.5", "label": "MiMo V2.5"}, + {"id": "kimi-k3", "label": "Kimi K3"}, + {"id": "kimi-k2.7-code", "label": "Kimi K2.7 Code"}, + {"id": "kimi-k2.6", "label": "Kimi K2.6"}, + {"id": "kimi-k2.5", "label": "Kimi K2.5"}, + {"id": "gpt-5.6-luna", "label": "GPT 5.6 Luna"}, + {"id": "grok-4.5", "label": "Grok 4.5"}, + {"id": "glm-5.3", "label": "GLM-5.3"}, + {"id": "glm-5.2", "label": "GLM-5.2"}, + {"id": "glm-5.1", "label": "GLM-5.1"}, + {"id": "glm-5", "label": "GLM-5"}, + {"id": "mimo-v2.5-pro", "label": "MiMo V2.5 Pro"}, + {"id": "mimo-v2.5", "label": "MiMo V2.5"}, + {"id": "mimo-v2-pro", "label": "MiMo V2 Pro"}, + {"id": "mimo-v2-omni", "label": "MiMo V2 Omni"}, + {"id": "minimax-m3", "label": "MiniMax M3"}, + {"id": "minimax-m2.7", "label": "MiniMax M2.7"}, + {"id": "minimax-m2.5", "label": "MiniMax M2.5"}, + {"id": "deepseek-v4-pro", "label": "DeepSeek V4 Pro"}, + {"id": "deepseek-v4-flash", "label": "DeepSeek V4 Flash"}, + {"id": "qwen3.8-max", "label": "Qwen3.8 Max"}, + {"id": "qwen3.7-max", "label": "Qwen3.7 Max"}, + {"id": "qwen3.7-plus", "label": "Qwen3.7 Plus"}, + {"id": "qwen3.6-plus", "label": "Qwen3.6 Plus"}, + {"id": "qwen3.5-plus", "label": "Qwen3.5 Plus"}, + {"id": "hy3", "label": "HY3"}, + {"id": "hy3-preview", "label": "HY3 Preview"}, + {"id": "muse-spark-1.2-contributor", "label": "Muse Spark 1.2 Contributor"}, + {"id": "ox-alpha-free", "label": "Ox Alpha (Go)"}, ], # 'gemini' is the hermes_cli provider ID for Google AI Studio # Model IDs are bare — sent directly to: diff --git a/tests/test_issue5311_opencode_go_live_models.py b/tests/test_issue5311_opencode_go_live_models.py index fccdb7f6..da925c94 100644 --- a/tests/test_issue5311_opencode_go_live_models.py +++ b/tests/test_issue5311_opencode_go_live_models.py @@ -165,3 +165,80 @@ def test_opencode_go_config_allowlist_still_wins(monkeypatch, tmp_path): assert calls == [] # probe never consulted assert _ids(group) == ["glm-5"] + + +# ── Static fallback list contract ───────────────────────────────────── +# The remaining tests pin the offline fallback list itself. It is an exact +# mirror of Hermes core's curated ``opencode-go`` list +# (hermes_cli/models.py) — core owns the sync duty against the live +# https://opencode.ai/zen/go/v1/models endpoint and +# https://opencode.ai/docs/go/, and WebUI only mirrors. This catches drift +# in either direction: ids core added (kimi-k3, gpt-5.6-luna, glm-5.3, +# grok-4.5, qwen3.8-max, hy3, hy3-preview, muse-spark-1.2-contributor, +# ox-alpha-free) and ids core dropped. + +import ast +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CONFIG_PATH = ROOT / "api" / "config.py" +CONFIG = CONFIG_PATH.read_text(encoding="utf-8") + +EXPECTED_OPENCODE_GO_MODEL_IDS = [ + "kimi-k3", + "kimi-k2.7-code", + "kimi-k2.6", + "kimi-k2.5", + "gpt-5.6-luna", + "grok-4.5", + "glm-5.3", + "glm-5.2", + "glm-5.1", + "glm-5", + "mimo-v2.5-pro", + "mimo-v2.5", + "mimo-v2-pro", + "mimo-v2-omni", + "minimax-m3", + "minimax-m2.7", + "minimax-m2.5", + "deepseek-v4-pro", + "deepseek-v4-flash", + "qwen3.8-max", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.6-plus", + "qwen3.5-plus", + "hy3", + "hy3-preview", + "muse-spark-1.2-contributor", + "ox-alpha-free", +] + + +def _opencode_go_static_models(): + tree = ast.parse(CONFIG, filename=str(CONFIG_PATH)) + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + if not any(isinstance(target, ast.Name) and target.id == "_PROVIDER_MODELS" for target in node.targets): + continue + provider_models = ast.literal_eval(node.value) + return provider_models["opencode-go"] + raise AssertionError("_PROVIDER_MODELS assignment not found") + + +def test_opencode_go_static_models_match_core_curated_list(): + models = _opencode_go_static_models() + assert [model["id"] for model in models] == EXPECTED_OPENCODE_GO_MODEL_IDS + + +def test_opencode_go_recent_additions_have_human_labels(): + labels = {model["id"]: model["label"] for model in _opencode_go_static_models()} + assert labels["gpt-5.6-luna"] == "GPT 5.6 Luna" + assert labels["kimi-k3"] == "Kimi K3" + assert labels["glm-5.3"] == "GLM-5.3" + assert labels["grok-4.5"] == "Grok 4.5" + assert labels["qwen3.8-max"] == "Qwen3.8 Max" + assert labels["muse-spark-1.2-contributor"] == "Muse Spark 1.2 Contributor" + assert labels["ox-alpha-free"] == "Ox Alpha (Go)" From dde61be8b243498e12a5f78a2ce8deaa859a8b17 Mon Sep 17 00:00:00 2001 From: shameez-struggles-to-commit Date: Fri, 21 Aug 2026 21:05:22 -0700 Subject: [PATCH 17/47] fix(models): gate OpenCode Go discovery by core version --- api/config.py | 28 ++++++++++ .../test_issue5311_opencode_go_live_models.py | 53 +++++++++++++++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/api/config.py b/api/config.py index b279e0e2..8a7d43b4 100644 --- a/api/config.py +++ b/api/config.py @@ -8388,6 +8388,26 @@ def _read_live_provider_model_ids(provider_id: str) -> list[str]: return [] +def _hermes_cli_supports_opencode_go_live_catalog() -> bool: + """Whether the installed Agent has Go-specific model discovery. + + Hermes core versions before 0.20.5 route ``opencode-go`` through a + generic public catalog. That lookup can return a convincing non-empty + list containing models the Go relay rejects with 404, so absence or an + unparseable/prerelease version must fail closed to WebUI's static Go list. + """ + try: + import hermes_cli + + version = str(getattr(hermes_cli, "__version__", "")).strip() + except Exception: + return False + match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", version) + if not match: + return False + return tuple(int(part) for part in match.groups()) >= (0, 20, 5) + + def _models_from_live_provider_ids(provider_id: str, live_ids: list[str]) -> list[dict]: """Convert Hermes CLI model ids into WebUI picker model entries.""" formatter = _format_ollama_label if provider_id in ("ollama", "ollama-cloud") else None @@ -9785,6 +9805,14 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa if not raw_models: if pid == "moa": raw_models = _moa_preset_models_from_config(cfg) + elif ( + pid == "opencode-go" + and not _hermes_cli_supports_opencode_go_live_catalog() + ): + # Before core v0.20.5 this resolver returned the + # generic public catalog, including models that + # 404 on the Go tier. Use the curated Go fallback. + raw_models = [] else: raw_models = _models_from_live_provider_ids( pid, diff --git a/tests/test_issue5311_opencode_go_live_models.py b/tests/test_issue5311_opencode_go_live_models.py index da925c94..51932411 100644 --- a/tests/test_issue5311_opencode_go_live_models.py +++ b/tests/test_issue5311_opencode_go_live_models.py @@ -32,10 +32,18 @@ def _scrub_provider_env(monkeypatch): monkeypatch.delenv(name, raising=False) -def _install_fake_hermes_cli(monkeypatch, *, provider_id: str, live_ids, raise_on_lookup: bool = False): +def _install_fake_hermes_cli( + monkeypatch, + *, + provider_id: str, + live_ids, + core_version: str = "0.20.5", + raise_on_lookup: bool = False, +): """Install a hermes_cli stub that reports one authenticated provider.""" fake_pkg = types.ModuleType("hermes_cli") fake_pkg.__path__ = [] + fake_pkg.__version__ = core_version fake_models = types.ModuleType("hermes_cli.models") fake_models.list_available_providers = lambda: [ @@ -123,6 +131,26 @@ def test_opencode_go_probes_live_catalog(monkeypatch, tmp_path): assert all(m.get("label") for m in group["models"]) +def test_opencode_go_old_core_ignores_nonempty_generic_catalog(monkeypatch, tmp_path): + """Pre-v0.20.5 cores must not leak their generic public catalog into Go.""" + _scrub_provider_env(monkeypatch) + calls = _install_fake_hermes_cli( + monkeypatch, + provider_id="opencode-go", + live_ids=["go-ineligible-old-core-sentinel"], + core_version="0.20.4", + ) + _configure(monkeypatch, tmp_path, provider="opencode-go") + + result = config.get_available_models() + group = _provider_group(result, "opencode-go") + + assert calls == [] + ids = _ids(group) + assert "go-ineligible-old-core-sentinel" not in ids + assert "kimi-k3" in ids + + def test_opencode_go_static_fallback_when_probe_fails(monkeypatch, tmp_path): """Offline / CLI failure must fall back to the curated static list.""" _scrub_provider_env(monkeypatch) @@ -168,9 +196,9 @@ def test_opencode_go_config_allowlist_still_wins(monkeypatch, tmp_path): # ── Static fallback list contract ───────────────────────────────────── -# The remaining tests pin the offline fallback list itself. It is an exact -# mirror of Hermes core's curated ``opencode-go`` list -# (hermes_cli/models.py) — core owns the sync duty against the live +# The remaining tests pin the offline fallback list itself to Hermes core's +# v0.20.5 release contract (commit fcbd1076, tag v2026.8.19). Core owns the +# sync duty against the live # https://opencode.ai/zen/go/v1/models endpoint and # https://opencode.ai/docs/go/, and WebUI only mirrors. This catches drift # in either direction: ids core added (kimi-k3, gpt-5.6-luna, glm-5.3, @@ -228,11 +256,26 @@ def _opencode_go_static_models(): raise AssertionError("_PROVIDER_MODELS assignment not found") -def test_opencode_go_static_models_match_core_curated_list(): +def test_opencode_go_static_models_match_v0205_release_snapshot(): models = _opencode_go_static_models() assert [model["id"] for model in models] == EXPECTED_OPENCODE_GO_MODEL_IDS +def test_opencode_go_static_models_match_installed_core_curated_list(): + """Exercise the actual Agent contract for the supported release pair.""" + import pytest + + hermes_cli = pytest.importorskip( + "hermes_cli", reason="hermes-agent is not a WebUI test dependency" + ) + if not config._hermes_cli_supports_opencode_go_live_catalog(): + pytest.skip(f"installed hermes-agent {hermes_cli.__version__} predates v0.20.5") + models_mod = pytest.importorskip("hermes_cli.models") + assert [model["id"] for model in _opencode_go_static_models()] == models_mod._PROVIDER_MODELS[ + "opencode-go" + ] + + def test_opencode_go_recent_additions_have_human_labels(): labels = {model["id"]: model["label"] for model in _opencode_go_static_models()} assert labels["gpt-5.6-luna"] == "GPT 5.6 Luna" From 1e2e26cde38883722f7ec8b9ce7f9fa12d7460e3 Mon Sep 17 00:00:00 2001 From: shameez-struggles-to-commit Date: Thu, 10 Sep 2026 07:34:08 -0700 Subject: [PATCH 18/47] chore(models): sync opencode-go fallback to core main (drop relay-delisted ox-alpha-free) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core's 2026-09-09 catalog sync (hermes_cli/models_catalog_static.py, unreleased as of v2026.9.7) dropped ox-alpha-free — the Go relay delisted it (GET /zen/go/v1/models omits it, POST returns 401) — and added glm-5.3-flash and muse-spark-1.3-contributor. Mirror core main (29 ids, verified exact match). Keeping the delisted id would offer a model that always 401s — the original #5311 failure class. The installed-core equality test now skips while released cores still carry the delisted id (transition window documented in the test); verified 7/7 green with PYTHONPATH pointed at core main where equality binds. --- api/config.py | 12 +++--- .../test_issue5311_opencode_go_live_models.py | 39 +++++++++++++------ 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/api/config.py b/api/config.py index 8a7d43b4..0d7042fa 100644 --- a/api/config.py +++ b/api/config.py @@ -1907,10 +1907,11 @@ _PROVIDER_MODELS = { # Fallback only: the live Hermes CLI catalog (Go-specific # /zen/go/v1/models probe, core v0.20.5+) leads (#1240, #5311). # Mirrors Hermes core's curated opencode-go list - # (hermes_cli/models.py, synced there against the live endpoint and - # https://opencode.ai/docs/go/ on 2026-08-20/21). `ox-alpha-free` is the - # Go-subscription twin of Zen's keyless Ox Alpha — same id shape, but it - # requires the Go key. + # (hermes_cli/models_catalog_static.py, core main as of 2026-09-10). + # Core's 2026-09-09 sync dropped `ox-alpha-free` (Go relay delisted it: + # GET /zen/go/v1/models omits it, POST → 401) and added `glm-5.3-flash` + # and `muse-spark-1.3-contributor`. Core owns the sync duty against the + # live endpoint; WebUI mirrors. "opencode-go": [ {"id": "kimi-k3", "label": "Kimi K3"}, {"id": "kimi-k2.7-code", "label": "Kimi K2.7 Code"}, @@ -1919,6 +1920,7 @@ _PROVIDER_MODELS = { {"id": "gpt-5.6-luna", "label": "GPT 5.6 Luna"}, {"id": "grok-4.5", "label": "Grok 4.5"}, {"id": "glm-5.3", "label": "GLM-5.3"}, + {"id": "glm-5.3-flash", "label": "GLM-5.3 Flash"}, {"id": "glm-5.2", "label": "GLM-5.2"}, {"id": "glm-5.1", "label": "GLM-5.1"}, {"id": "glm-5", "label": "GLM-5"}, @@ -1939,7 +1941,7 @@ _PROVIDER_MODELS = { {"id": "hy3", "label": "HY3"}, {"id": "hy3-preview", "label": "HY3 Preview"}, {"id": "muse-spark-1.2-contributor", "label": "Muse Spark 1.2 Contributor"}, - {"id": "ox-alpha-free", "label": "Ox Alpha (Go)"}, + {"id": "muse-spark-1.3-contributor", "label": "Muse Spark 1.3 Contributor"}, ], # 'gemini' is the hermes_cli provider ID for Google AI Studio # Model IDs are bare — sent directly to: diff --git a/tests/test_issue5311_opencode_go_live_models.py b/tests/test_issue5311_opencode_go_live_models.py index 51932411..a15daa12 100644 --- a/tests/test_issue5311_opencode_go_live_models.py +++ b/tests/test_issue5311_opencode_go_live_models.py @@ -197,13 +197,13 @@ def test_opencode_go_config_allowlist_still_wins(monkeypatch, tmp_path): # ── Static fallback list contract ───────────────────────────────────── # The remaining tests pin the offline fallback list itself to Hermes core's -# v0.20.5 release contract (commit fcbd1076, tag v2026.8.19). Core owns the -# sync duty against the live +# current curated contract (hermes_cli/models_catalog_static.py, core main +# 2026-09-10; the list originated in v0.20.5, commit fcbd1076). Core owns +# the sync duty against the live # https://opencode.ai/zen/go/v1/models endpoint and # https://opencode.ai/docs/go/, and WebUI only mirrors. This catches drift -# in either direction: ids core added (kimi-k3, gpt-5.6-luna, glm-5.3, -# grok-4.5, qwen3.8-max, hy3, hy3-preview, muse-spark-1.2-contributor, -# ox-alpha-free) and ids core dropped. +# in either direction: ids core added and ids core dropped (e.g. +# ox-alpha-free, delisted from the Go relay on 2026-09-09). import ast from pathlib import Path @@ -220,6 +220,7 @@ EXPECTED_OPENCODE_GO_MODEL_IDS = [ "gpt-5.6-luna", "grok-4.5", "glm-5.3", + "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5", @@ -240,7 +241,7 @@ EXPECTED_OPENCODE_GO_MODEL_IDS = [ "hy3", "hy3-preview", "muse-spark-1.2-contributor", - "ox-alpha-free", + "muse-spark-1.3-contributor", ] @@ -256,13 +257,21 @@ def _opencode_go_static_models(): raise AssertionError("_PROVIDER_MODELS assignment not found") -def test_opencode_go_static_models_match_v0205_release_snapshot(): +def test_opencode_go_static_models_match_core_catalog_snapshot(): models = _opencode_go_static_models() assert [model["id"] for model in models] == EXPECTED_OPENCODE_GO_MODEL_IDS def test_opencode_go_static_models_match_installed_core_curated_list(): - """Exercise the actual Agent contract for the supported release pair.""" + """Exercise the actual Agent contract for the supported release pair. + + The snapshot mirrors core main's curated catalog. A released (installed) + core can lag that sync by a few days — e.g. v2026.9.7 still lists + ``ox-alpha-free``, which core removed on 2026-09-09 after the Go relay + delisted it. Equality binds only once the installed core carries the + mirrored sync; the marker for "lags the sync" is the delisted id still + being present. Anything else that differs is real drift and must fail. + """ import pytest hermes_cli = pytest.importorskip( @@ -271,9 +280,14 @@ def test_opencode_go_static_models_match_installed_core_curated_list(): if not config._hermes_cli_supports_opencode_go_live_catalog(): pytest.skip(f"installed hermes-agent {hermes_cli.__version__} predates v0.20.5") models_mod = pytest.importorskip("hermes_cli.models") - assert [model["id"] for model in _opencode_go_static_models()] == models_mod._PROVIDER_MODELS[ - "opencode-go" - ] + installed = list(models_mod._PROVIDER_MODELS["opencode-go"]) + if "ox-alpha-free" in installed: + pytest.skip( + "installed hermes-agent catalog predates core's 2026-09-09 sync " + "(still lists relay-delisted 'ox-alpha-free'); equality binds " + "once a release carrying that sync is installed" + ) + assert [model["id"] for model in _opencode_go_static_models()] == installed def test_opencode_go_recent_additions_have_human_labels(): @@ -284,4 +298,5 @@ def test_opencode_go_recent_additions_have_human_labels(): assert labels["grok-4.5"] == "Grok 4.5" assert labels["qwen3.8-max"] == "Qwen3.8 Max" assert labels["muse-spark-1.2-contributor"] == "Muse Spark 1.2 Contributor" - assert labels["ox-alpha-free"] == "Ox Alpha (Go)" + assert labels["muse-spark-1.3-contributor"] == "Muse Spark 1.3 Contributor" + assert labels["glm-5.3-flash"] == "GLM-5.3 Flash" From e3c4a0fef14b32fff2c093fb6b2699cd722e6883 Mon Sep 17 00:00:00 2001 From: shameez-struggles-to-commit Date: Thu, 10 Sep 2026 09:14:12 -0700 Subject: [PATCH 19/47] docs: add OpenCode Go picker troubleshooting entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-visible runtime behavior changed in this PR (live catalog first for Agent >= 0.20.5, resynced static fallback): document both failure directions users can now hit — listed-but-failing ids (tier/region gates on the live endpoint, the #7507 follow-up) and missing new ids on older-AGENT installs — with the diagnostic (hermes --version + authed curl of /zen/go/v1/models) and fixes (allowlist precedence, Agent upgrade). Follows the file's Symptom/Why/Diagnostic/Fix/File-a-bug format. --- docs/troubleshooting.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 35cba162..13daf3c9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -241,6 +241,44 @@ For a foreground `python3 bootstrap.py`, stop it with Ctrl-C and start it again. --- +## "OpenCode Go model picker shows a model that errors when you send" (or is missing newly released models) + +**Symptom.** One of two directions: + +1. A model selected from the OpenCode Go group fails on the first message (`model not found`, `Model is unavailable`, or a region error), even though the picker offered it. +2. A newly released OpenCode Go model does not appear in the picker at all, and must be typed into the Custom Model ID box. + +**Why.** The Go picker follows the **live** Go-tier catalog (`https://opencode.ai/zen/go/v1/models`) whenever the installed Hermes Agent is v0.20.5 or newer. That endpoint advertises a superset of what a given tier, key, or region can actually serve — an id can be listed and still fail on send. Conversely, an id the endpoint serves but the WebUI's static fallback list predates is missing when the live path is unavailable (Agent older than v0.20.5, probe failure, or offline); the static list mirrors Hermes core's curated Go catalog and can lag new releases by design. + +**Diagnostic.** Check which path is feeding your picker: + +```bash +hermes --version # live path requires >= 0.20.5 +curl -sS https://opencode.ai/zen/go/v1/models \ + -H "Authorization: Bearer $OPENCODE_GO_API_KEY" | head -50 +``` + +If the failing model is absent from the `curl` output, it was delisted upstream (for example `ox-alpha-free`, removed from the relay on 2026-09-09) and only a stale fallback would offer it — upgrade the WebUI so the fallback resyncs. + +**Fix.** + +- For a listed-but-failing model (tier-gated or region-blocked): pin the models you actually use with an explicit allowlist in `config.yaml`, which takes precedence over both the live catalog and the fallback: + + ```yaml + providers: + opencode-go: + models: + - kimi-k3 + - glm-5.3 + ``` + + A lighter per-provider exclude capability is tracked in #7507. +- For a missing new model: upgrade Hermes Agent to ≥ v0.20.5 so the picker reads the live catalog instead of the static fallback. + +**When to file a bug.** File a WebUI bug if a model fails on send *and* appears in the `curl` output for your key (a routing problem), or if a model is missing with Agent ≥ v0.20.5 and the live catalog reachable (fallback used when it should not be). + +--- + ## Other troubleshooting This document grows over time. If a recurring failure mode isn't covered here yet, add it via PR. The format for each entry: **Symptom → Why → Diagnostic commands → Fix → When to file a bug**. From f5170004a30e0cccbce11af4f4354985d23eee04 Mon Sep 17 00:00:00 2001 From: shameez-struggles-to-commit Date: Thu, 10 Sep 2026 09:55:27 -0700 Subject: [PATCH 20/47] docs: correct Go picker guidance from runtime verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against a real install (core v0.21.0 + live key + endpoint): - on Agent releases between v0.20.5 and the 2026-09-09 catalog sync, the delisted ox-alpha-free can STILL be offered — via core's own live/curated merge AND via _seed_provider_models_from_core() injecting the installed core's stale curated ids into WebUI's static list. 'Upgrade the Agent past the sync' is the correct guidance, not 'upgrade the WebUI'. - allowlist precedence re-verified with a real config.yaml on disk (group == exactly the allowlisted ids). - note the 4s foreground rebuild budget: first picker open after a cold start can serve last-known catalog while the live rebuild finishes out-of-band. --- docs/troubleshooting.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 13daf3c9..e74a0005 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -258,11 +258,10 @@ curl -sS https://opencode.ai/zen/go/v1/models \ -H "Authorization: Bearer $OPENCODE_GO_API_KEY" | head -50 ``` -If the failing model is absent from the `curl` output, it was delisted upstream (for example `ox-alpha-free`, removed from the relay on 2026-09-09) and only a stale fallback would offer it — upgrade the WebUI so the fallback resyncs. +Interpret the two together: -**Fix.** - -- For a listed-but-failing model (tier-gated or region-blocked): pin the models you actually use with an explicit allowlist in `config.yaml`, which takes precedence over both the live catalog and the fallback: +- **Failing model absent from the `curl` output** → it was delisted upstream (for example `ox-alpha-free`, removed from the relay on 2026-09-09). A picker can still offer it from a **stale catalog merge**: Hermes core merges its own curated Go list into the live result, and Agent releases *between* v0.20.5 and the 2026-09-09 catalog sync (through v0.21.1) still carry the delisted id in that list — verified at runtime against v0.21.0, where the live path serves 37 ids including `ox-alpha-free`. **Upgrade the Agent** past the 2026-09-09 sync (or pin an allowlist below) so the dead id stops being offered. +- **Failing model present in the `curl` output** → the relay lists it but your tier/region cannot serve it; the picker is behaving correctly. Pin the models you actually use with an explicit allowlist in `config.yaml`, which takes precedence over both the live catalog and the fallback: ```yaml providers: @@ -273,7 +272,8 @@ If the failing model is absent from the `curl` output, it was delisted upstream ``` A lighter per-provider exclude capability is tracked in #7507. -- For a missing new model: upgrade Hermes Agent to ≥ v0.20.5 so the picker reads the live catalog instead of the static fallback. +- **Missing new model, Agent ≥ v0.20.5** → the live catalog is the source; refresh or check the endpoint with the `curl` above (cold rebuilds are also bounded by a 4-second foreground budget — the first picker open after a restart can serve the last-known list while the live rebuild finishes in the background, so re-open the picker once before concluding it's stale). +- **Missing new model, Agent older than v0.20.5** → the static fallback is serving by design; upgrade the Agent to ≥ v0.20.5 so the picker reads the live catalog. **When to file a bug.** File a WebUI bug if a model fails on send *and* appears in the `curl` output for your key (a routing problem), or if a model is missing with Agent ≥ v0.20.5 and the live catalog reachable (fallback used when it should not be). From 4f6902bcc0ba1098361e6fa5c9ef9c1cc6644cab Mon Sep 17 00:00:00 2001 From: shameez-struggles-to-commit Date: Thu, 10 Sep 2026 10:03:46 -0700 Subject: [PATCH 21/47] docs: make the delisted-id guidance actionable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile's P2 was right: 'upgrade past the sync' had no installable target. Version audit across core tags: every RELEASED Agent (0.20.5 -> 0.21.1, tag v2026.9.7) still carries ox-alpha-free in the curated floor; the removal exists only on core main, which self-reports the same 0.21.1 version string as the stale tag — a version number alone cannot distinguish fixed from unfixed. State that plainly and present the config allowlist as the only verified workaround until a release notes the 2026-09-09 catalog sync. --- docs/troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e74a0005..92481a53 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -260,7 +260,7 @@ curl -sS https://opencode.ai/zen/go/v1/models \ Interpret the two together: -- **Failing model absent from the `curl` output** → it was delisted upstream (for example `ox-alpha-free`, removed from the relay on 2026-09-09). A picker can still offer it from a **stale catalog merge**: Hermes core merges its own curated Go list into the live result, and Agent releases *between* v0.20.5 and the 2026-09-09 catalog sync (through v0.21.1) still carry the delisted id in that list — verified at runtime against v0.21.0, where the live path serves 37 ids including `ox-alpha-free`. **Upgrade the Agent** past the 2026-09-09 sync (or pin an allowlist below) so the dead id stops being offered. +- **Failing model absent from the `curl` output** → it was delisted upstream (for example `ox-alpha-free`, removed from the relay on 2026-09-09). A picker can still offer it from a **stale catalog merge**: Hermes core merges its own curated Go list into the live result, and every Agent release *through v0.21.1 (tag `v2026.9.7`)* still carries the delisted id in that list — verified at runtime against v0.21.0, where the live path serves 37 ids including `ox-alpha-free`. The removal is committed on core `main` (2026-09-09 sync) but **no released Agent version includes it yet** — and `main` reports the same `0.21.1` version string as the stale tag, so a version number alone cannot tell you whether the fix is in. Until a release notes the 2026-09-09 catalog sync, the **verified workaround is the config allowlist** (below); selecting the dead entry is harmless to other models (it errors on send, nothing else). - **Failing model present in the `curl` output** → the relay lists it but your tier/region cannot serve it; the picker is behaving correctly. Pin the models you actually use with an explicit allowlist in `config.yaml`, which takes precedence over both the live catalog and the fallback: ```yaml From 2e583b501b26e08d62c4fa8465ec926b37a7b2a9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 22 Sep 2026 07:01:19 +0000 Subject: [PATCH 22/47] docs(changelog): stamp #7220 opencode-go live model discovery --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b455fe75..384806a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ ### Fixed +- **The opencode-go provider lists its real models again instead of a frozen snapshot.** Model + discovery had fallen back to a hard-coded catalog, so models added or removed upstream never + appeared. The provider now queries live, with the lookup scoped to opencode-go alone: a slow + or unreachable endpoint falls back to the static catalog rather than blocking the picker for + anyone else, older Agent builds that lack the discovery API keep the previous behaviour, and a + configured model allowlist still wins over whatever discovery returns. Thanks + @shameez-struggles-to-commit. (#7220) + - **Denying a gateway approval now retires the request instead of leaving the run waiting.** When an approval routed through the gateway was denied, the deny reached the agent but the local producer was never retired, so the run could sit waiting on a decision that had already From 1692296c105cc149b4e3e9b2a38e9c3a66ecaa12 Mon Sep 17 00:00:00 2001 From: KayZz69 Date: Mon, 24 Aug 2026 08:58:26 +0000 Subject: [PATCH 23/47] fix(streaming): tolerate null tool call history --- api/streaming.py | 2 +- tests/test_tool_call_persistence.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/api/streaming.py b/api/streaming.py index 5b8f4108..e924dc10 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -7950,7 +7950,7 @@ def _extract_tool_calls_from_messages(messages, live_tool_calls=None): pending_names[tid] = part.get('name', '') pending_args[tid] = part.get('input', {}) pending_asst_idx[tid] = msg_idx - for tc in m.get('tool_calls', []): + for tc in m.get('tool_calls') or []: if not isinstance(tc, dict): continue tid = tc.get('id', '') or tc.get('call_id', '') diff --git a/tests/test_tool_call_persistence.py b/tests/test_tool_call_persistence.py index 050b2443..3f86c127 100644 --- a/tests/test_tool_call_persistence.py +++ b/tests/test_tool_call_persistence.py @@ -33,6 +33,34 @@ def test_extract_tool_calls_from_openai_message_linkage(): assert result[0]["snippet"] == "file.txt" +def test_extract_tool_calls_ignores_explicit_null_tool_calls_in_history(): + """Legacy assistant rows with tool_calls=None must not abort later turns.""" + messages = [ + {"role": "assistant", "content": "An old TickTick response", "tool_calls": None}, + {"role": "user", "content": "continue"}, + { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call-current", + "function": {"name": "terminal", "arguments": '{"command":"pwd"}'}, + }], + }, + { + "role": "tool", + "tool_call_id": "call-current", + "content": '{"output":"/workspace","exit_code":0}', + }, + ] + + result = _extract_tool_calls_from_messages(messages) + + assert len(result) == 1 + assert result[0]["name"] == "terminal" + assert result[0]["assistant_msg_idx"] == 2 + assert result[0]["snippet"] == "/workspace" + + def test_tool_result_snippet_allows_frontend_show_more_threshold_but_stays_bounded(): """Persisted snippets should be long enough for frontend Show more but capped.""" medium_output = "m" * 1200 From 22de4aa769506066f254ad64bf75f25ecb3dc6e2 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 22 Sep 2026 07:41:42 +0000 Subject: [PATCH 24/47] docs(changelog): stamp #7265 null tool_calls tolerance --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 384806a1..7e0aedf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ ### Fixed +- **A conversation whose history carries an explicit `null` tool-call list no longer breaks + the tool-call summary.** `_extract_tool_calls_from_messages` used `.get('tool_calls', [])`, + which returns the default only when the key is *absent* — when a stored message carried the + key with a `null` value, the code tried to iterate `None` and raised `TypeError`. Reading + such a session now skips the empty entry and still summarises the tool calls that follow it. + Thanks @KayZz69. (#7265) + - **The opencode-go provider lists its real models again instead of a frozen snapshot.** Model discovery had fallen back to a hard-coded catalog, so models added or removed upstream never appeared. The provider now queries live, with the lookup scoped to opencode-go alone: a slow From c53226dcc4944ee2103b023765fe696fd80633df Mon Sep 17 00:00:00 2001 From: happy5318 Date: Tue, 22 Sep 2026 16:37:02 +0800 Subject: [PATCH 25/47] fix(login): hide the password form when ``is_password_auth_enabled()`` is False (#7056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path With native OIDC configured and ``HERMES_WEBUI_PASSWORD`` unset, the ``/api/auth/status`` endpoint correctly reports ``password_auth_enabled: false``, but ``/login`` still rendered the password input, the "Sign in" button, and the "Continue with SSO" entry point — and silently 401-ed every submit. The reporter's wire evidence: ```json GET /api/auth/status -> { "auth_enabled": true, "oidc_enabled": true, "password_auth_enabled": false } GET /login -> still shows: "Enter your password to continue" + password + "Sign in" button + "Continue with SSO" link POST /api/auth/login -> 401 {"error":"Invalid password"} ``` The fix routes the password input / submit / passkey controls through a single ``{{PASSWORD_FORM_HTML}}`` placeholder in the template, which the handler populates with the live markup only when ``is_password_auth_enabled()`` is True. The OIDC SSO entry point stays the sole path when password auth is disabled. ## What Changed 1. ``api/routes.py`` template literal (``_LOGIN_PAGE_HTML``): the three inline elements (password input, submit button, passkey button) are replaced by a single ``{{PASSWORD_FORM_HTML}}`` placeholder. The ``{{OIDC_LOGIN_HTML}}`` placeholder is kept untouched. 2. ``api/routes.py`` login branch: the handler imports ``is_password_auth_enabled`` from ``api.auth``, computes the password form markup when the predicate returns True, and an empty string otherwise. The ``.replace("{{PASSWORD_FORM_HTML}}", _password_form_html)`` call slots the result into the template. The ``{{LOGIN_PLACEHOLDER}}``, ``{{LOGIN_BTN}}``, ``{{LOGIN_INVALID_PW}}``, ``{{LOGIN_CONN_FAILED}}`` placeholders are no longer referenced from the template (the password input is built with its own ``placeholder`` attribute), so the four corresponding ``.replace(...)`` calls are removed. ## Why It Matters A user with OIDC-only auth clicking "Sign in" on the password form gets a 401 with no UI signal — the OIDC entry point is right there, but the password prompt was so prominent it read like the primary path. Hiding the form when password auth is off makes OIDC the only visible option, so the user is funneled to the working path. ## Verification - ``tests/test_issue7056_login_hides_password_form.py`` (new, 6 tests): - **Source-shape (2 tests):** the template uses the ``{{PASSWORD_FORM_HTML}}`` placeholder AND does not carry an inline ````; a silent reversion to the pre-fix multi-line shape fails the suite. The login branch imports ``is_password_auth_enabled`` and populates the placeholder. - **Behavioural (3 tests):** the rendered ``/login`` HTML contains the password input / submit / passkey controls when ``is_password_auth_enabled()`` is True; does NOT contain them when False; and keeps the ```` SSO entry point when OIDC is enabled (so the user can still log in). - **Sanity (1 test):** with both password and OIDC disabled, the page renders neither control. - 51 tests pass across the related auth/login files (``test_issue7056_login_hides_password_form``, ``test_auth_settings_safety``, ``test_issue3825_oidc_auth``) — no pre-existing test is broken. ## Contract Change None. The ``/login`` HTML body changes shape only when ``is_password_auth_enabled()`` is False; the password input + submit + passkey controls are gone, but the OIDC SSO entry point stays present. The ``/api/auth/status`` contract is unchanged — it already reported the right ``password_auth_enabled`` flag; the fix is purely about the page matching the status. ## Risks - A consumer that scraped the ``/login`` HTML for the password input (e.g. a browser extension) will see the input disappear when ``HERMES_WEBUI_PASSWORD`` is unset. The OIDC entry point remains the only path, which is the intended behaviour when password auth is disabled. - The handler now imports ``is_password_auth_enabled`` at request time (same module as the other auth predicates already imported); if the auth module is unavailable the import fails fast and the ``/login`` route surfaces the underlying exception, matching the behaviour of the other auth imports in the same branch. ## Related - #7056 — original issue (segator, 2026-08-15) with the wire evidence and the expected-vs-actual contract. - The OIDC SSO entry point lives in ``_oidc_login_html`` (line 11160) and is unaffected by this PR — the fix only gates the password controls, not the SSO button. ## Model Used Hermes Agent (step-5-preview) with pytest, ruff. Tests use ``_FakeHandler`` with an ``io.BytesIO`` ``wfile`` (matching ``_safe_write``'s ``handler.wfile.write(body)`` expectation) and mock the OIDC predicate through ``_stub_oidc`` so the behavioural assertions don't depend on real OIDC env config. --- api/routes.py | 31 ++- ...est_issue7056_login_hides_password_form.py | 223 ++++++++++++++++++ 2 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 tests/test_issue7056_login_hides_password_form.py diff --git a/api/routes.py b/api/routes.py index 6afd81ad..1645084a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -11088,9 +11088,7 @@ button:hover{background:rgba(124,185,255,.25)}

{{BOT_NAME}}

{{LOGIN_SUBTITLE}}

- - - + {{PASSWORD_FORM_HTML}} {{OIDC_LOGIN_HTML}}
@@ -13633,6 +13631,24 @@ def handle_get(handler, parsed) -> bool: ] from urllib.parse import quote from api.updates import WEBUI_VERSION + # #7056: only render the password input / submit / passkey controls + # when password auth is actually enabled. With native OIDC configured + # and ``HERMES_WEBUI_PASSWORD`` unset, the form previously still + # displayed the password prompt and accepted — silently 401-ing at + # the server — every submit. The OIDC SSO entry point stays the + # sole path. ``is_password_auth_enabled`` is the same predicate + # ``/api/auth/status`` reports as ``password_auth_enabled``. + from api.auth import is_password_auth_enabled + if is_password_auth_enabled(): + _password_form_html = ( + f'' + f'' + '' + ) + else: + _password_form_html = "" version_token = quote(WEBUI_VERSION, safe="") _page = ( _LOGIN_PAGE_HTML.replace("{{BOT_NAME}}", _bn) @@ -13641,14 +13657,7 @@ def handle_get(handler, parsed) -> bool: .replace("{{LANG}}", _html.escape(_login_strings["lang"])) .replace("{{LOGIN_TITLE}}", _html.escape(_login_strings["title"])) .replace("{{LOGIN_SUBTITLE}}", _html.escape(_login_strings["subtitle"])) - .replace( - "{{LOGIN_PLACEHOLDER}}", _html.escape(_login_strings["placeholder"]) - ) - .replace("{{LOGIN_BTN}}", _html.escape(_login_strings["btn"])) - .replace("{{LOGIN_INVALID_PW}}", _html.escape(_login_strings["invalid_pw"])) - .replace( - "{{LOGIN_CONN_FAILED}}", _html.escape(_login_strings["conn_failed"]) - ) + .replace("{{PASSWORD_FORM_HTML}}", _password_form_html) .replace("{{OIDC_LOGIN_HTML}}", _oidc_login_html(parsed)) ) return t(handler, _page, content_type="text/html; charset=utf-8") diff --git a/tests/test_issue7056_login_hides_password_form.py b/tests/test_issue7056_login_hides_password_form.py new file mode 100644 index 00000000..dd4b4586 --- /dev/null +++ b/tests/test_issue7056_login_hides_password_form.py @@ -0,0 +1,223 @@ +"""Regression tests for issue #7056 — login page must hide the password form +when password auth is disabled (e.g. native OIDC configured and +``HERMES_WEBUI_PASSWORD`` unset). + +The original bug: ``/api/auth/status`` correctly reports +``password_auth_enabled: false`` when password auth is disabled, but +``/login`` still rendered the password input, the "Sign in" button, +and the "Continue with SSO" entry point — and silently 401-ed every +submit. The fix routes the password input / submit / passkey controls +through a single ``{{PASSWORD_FORM_HTML}}`` placeholder in the +template, which the handler populates with the live markup only when +``is_password_auth_enabled()`` is True. + +The OIDC SSO button stays the sole path when password auth is disabled. +""" +from __future__ import annotations + +import io +from unittest import mock + +import pytest + +import api.routes as routes +from api.routes import _LOGIN_PAGE_HTML + + +# --------------------------------------------------------------------------- +# Source-shape: the template + handler must agree on a single placeholder +# for the password controls, and the handler must call the live predicate +# --------------------------------------------------------------------------- + + +def test_login_template_uses_password_form_placeholder() -> None: + """The login template MUST carry a single ``{{PASSWORD_FORM_HTML}}`` placeholder. + + A silent reversion to the previous multi-line ```` + + ``
' + ) if is_password_auth_enabled(): _password_form_html = ( f'' f'' - '' + f'{_passkey_button_html}' ) + elif are_passkeys_enabled(): + _password_form_html = _passkey_button_html else: _password_form_html = "" version_token = quote(WEBUI_VERSION, safe="") @@ -13658,6 +13670,10 @@ def handle_get(handler, parsed) -> bool: .replace("{{LOGIN_TITLE}}", _html.escape(_login_strings["title"])) .replace("{{LOGIN_SUBTITLE}}", _html.escape(_login_strings["subtitle"])) .replace("{{PASSWORD_FORM_HTML}}", _password_form_html) + .replace("{{LOGIN_INVALID_PW}}", _html.escape(_login_strings["invalid_pw"])) + .replace( + "{{LOGIN_CONN_FAILED}}", _html.escape(_login_strings["conn_failed"]) + ) .replace("{{OIDC_LOGIN_HTML}}", _oidc_login_html(parsed)) ) return t(handler, _page, content_type="text/html; charset=utf-8") diff --git a/static/login.js b/static/login.js index 3bba3bb1..d702ca67 100644 --- a/static/login.js +++ b/static/login.js @@ -7,7 +7,11 @@ document.addEventListener('DOMContentLoaded', function () { var input = document.getElementById('pw'); var passkeyBtn = document.getElementById('passkey-login'); - if (!form || !input) return; + // #7056: the password input is absent on a passwordless deployment (OIDC-only, + // or passkey-only). The form itself still renders and carries the i18n data + // attributes, and the passkey button below must still be wired up, so only the + // form is required here — every password-specific path is guarded individually. + if (!form) return; var invalidPw = form.getAttribute('data-invalid-pw') || 'Invalid password'; var connFailed = form.getAttribute('data-conn-failed') || 'Connection failed'; @@ -59,6 +63,8 @@ document.addEventListener('DOMContentLoaded', function () { async function doLogin(e) { e.preventDefault(); + // No password input on a passwordless deployment: nothing to submit. + if (!input) return; var pw = input.value; hideErr(); try { @@ -147,12 +153,14 @@ document.addEventListener('DOMContentLoaded', function () { passkeyBtn.addEventListener('click', doPasskeyLogin); } - input.addEventListener('keydown', function (e) { - if (e.key === 'Enter') { - e.preventDefault(); - doLogin(e); - } - }); + if (input) { + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + doLogin(e); + } + }); + } // On page load, probe the server so we can distinguish "can't reach server" // (Tailscale off, wrong network) from "session expired / need to log in". From 68788ab5c63da9f66e83da8197c7f9b74fc3c71e Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 22 Sep 2026 09:58:47 +0000 Subject: [PATCH 27/47] docs(changelog): stamp #7715 passwordless login form --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0aedf6..7725c950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ ### Fixed +- **An OIDC-only or passkey-only deployment no longer shows a dead password prompt.** The + `/login` page rendered the password input, submit button and passkey control + unconditionally, so an instance with native OIDC configured and no + `HERMES_WEBUI_PASSWORD` displayed a password form that silently 401'd every submit. The + controls are now gated on the auth methods actually configured, with the passkey button + kept for passwordless-passkey instances where it is the only login affordance. Thanks + @happy5318. (#7056, #7715) + - **A conversation whose history carries an explicit `null` tool-call list no longer breaks the tool-call summary.** `_extract_tool_calls_from_messages` used `.get('tool_calls', [])`, which returns the default only when the key is *absent* — when a stored message carried the From 5518e4ceca8dd7c5a2608412aab7f0a1b203e0eb Mon Sep 17 00:00:00 2001 From: happy5318 Date: Tue, 22 Sep 2026 15:28:49 +0800 Subject: [PATCH 28/47] fix(config): drop synthetic ``{provider}/{model}`` alias from configured-model badges (#7290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path When the active provider is a built-in plugin provider (e.g. ``commandcode``) and the configured default model id contains a slash (e.g. ``deepseek/deepseek-v4-flash``), ``_build_configured_model_badges`` and ``_configured_model_badges_from_static_catalog`` used to publish three raw_candidates for the same configured identity: - the bare native id (``deepseek/deepseek-v4-flash``) - a slash-prefixed alias (``commandcode/deepseek/deepseek-v4-flash``) - the ``@provider:model``-prefixed form (``@commandcode:deepseek/deepseek-v4-flash``) The slash-prefixed alias was synthetic — the catalog never actually exposed that key. The WebUI dropdown rendered it as a real option, selecting it sent ``model=commandcode/deepseek/deepseek-v4-flash`` to the agent, and the provider rejected it with ``HTTP 400: Model ... is not supported on this endpoint``. The session also persisted the wrong id, so reload replayed the failure. Verified by reading the live source at master: the catalog exposes only the bare native id for built-in plugin providers; the slash-prefixed alias was a three-line entry in the ``raw_candidates`` tuple. Maintainer's 9/25 pickup contract on the issue explicitly prohibits positional slash-splitting for ownership inference: slashes are valid in model ids (multi-slash, URI-scheme, host:port). The fix drops only the synthetic prefix form; the bare native id (the actual catalog form) and the documented ``@provider:model`` prefix stay. ## What Changed 1. ``api/config.py:8617`` (``_build_configured_model_badges``) — drop the ``f"{provider}/{model}"`` entry from the ``raw_candidates`` tuple. The tuple now contains only ``model`` and ``f"@{provider}:{model}"``. Also type-annotate the local list. 2. ``api/config.py:6946`` (``_configured_model_badges_from_static_catalog``) — same one-line change for the static-catalog fallback path so the two builders stay in sync. The match_id / norm_lookup / exact_match logic downstream is unchanged — those already prefer the matching provider, and with the synthetic alias gone the bare native id is the only remaining self-collision candidate (which the existing ``test_duplicate_slash_id_primary_badge_sticks_to_matching_provider_only`` already pins). ## Why It Matters A user picking the configured default model in the dropdown sees two visually identical entries for the same model id, picks the wrong "prefixed" one (the one matching the dropdown's order), the agent returns 400, and the session state is corrupted with a non-functional model id until the user manually clears it. The dropdown's own deduplication already handles `@provider:model` correctly via the documented prefix form; only the synthesised ``{provider}/{model}`` form was wrong. ## Verification - ``tests/test_issue7290_no_slash_prefixed_alias.py`` (new, 10 tests): - ``test_commandcode_deepseek_does_not_emit_slash_prefixed_alias`` — the literal repro from the issue: ``commandcode/deepseek/deepseek-v4-flash`` MUST NOT appear in the badge map; the bare id ``deepseek/deepseek-v4-flash`` is the primary badge. - ``test_commandcode_deepseek_at_prefixed_alias_still_present`` — the ``@provider:model`` form is preserved. - ``test_duplicate_slash_id_does_not_pollute_other_provider`` — per contract 4: dedupe only when provider + full native model match. - ``test_non_slash_models_unchanged`` (parametrized × 4) — the bare, @-prefixed, URI-scheme, and URL model id forms all keep working. - ``test_synthetic_slash_prefix_alias_is_not_in_source`` (parametrized × 2) — the synthesised ``f"{provider}/{model}"`` literal MUST NOT appear in either builder; a silent reversion to the bug fails the suite. - ``test_raw_candidates_only_holds_bare_and_at_prefix`` — pins the shape of the ``for candidate in (...)`` tuple so a future regression that adds any *other* synthetic prefix form (``f"{provider}::{model}"``, etc.) also fails. - ``tests/test_model_picker_badges.py::test_available_models_exposes_primary_and_fallback_badges`` updated to assert the bare-id form (``claude-haiku-4.5``) and the ``@provider:model`` form (``@anthropic:claude-haiku-4.5``) carry the fallback badge; the deleted slash-prefixed form is explicitly asserted absent. All other test_model_picker_badges cases unchanged. - The 4 sibling tests (``test_issue3360_multi_slash_model_collision``, ``test_issue3429_uri_scheme_model_ids``, ``test_issue3959_model_suffix_dedup``, ``test_norm_model_id_trailing_empty_guard``) still pass — they pin ``_norm_model_id`` behaviour which is unchanged by this PR. - ``pytest tests/ -k 'model or picker or badge'``: 1507 passed, 1 skipped, 2 failed. The 2 failures (test_issue1426_free_tier, test_issue2245_mixed_case) are the documented upstream shard-race flake — both pass when run individually (verified). Pre-existing per issue #7656's notes. - ``ruff check api/config.py tests/test_issue7290_no_slash_prefixed_alias.py``: clean on the new test file. (The 4 pre-existing F811/import-order findings in api/config.py are on untouched lines.) ## Contract Change No API contract change. The ``/api/models`` payload's ``configured_model_badges`` map drops one synthetic key per configured entry (the slash-prefixed alias). Real consumers that look up a badge by exact native id or by ``@provider:model`` are unaffected. The dropdown's existing dedupe is now correct without a manual workaround. ## Risks - A consumer that depended on the slash-prefixed alias as a stable lookup key would need to switch to the bare native id. No consumer in this repo matches that pattern — ``test_duplicate_slash_id_* _primary_badge_sticks_to_matching_provider_only`` was already asserting that the bare id is the canonical lookup. - The dropdown's option value (the string sent as ``model=`` to the agent) is the bare native id; the test runner, the session restore path, and the request payload all use the bare id consistently per maintainer contract 3. ## Model Used Hermes Agent (step-5-preview) with pytest, ruff. Tests execute the live ``_build_configured_model_badges`` from the production source (``exec`` the function body with inline ``_norm_model_id``), so a revert to the original ``raw_candidates`` tuple fails the suite immediately on the source-shape test. ## Related - #7290 — original issue (Porter-Key, 2026-08-25) with the full repro and the maintainer's 9/25 pickup contract (6 items). - #3478 — named custom-provider ``@custom:name:model`` prefix. - #7241, #6884 — adjacent ``@provider:model`` prefix lanes. - #3360 — multi-slash model id collision (the ``_norm_model_id`` fix predates this PR and is preserved). - #3429 — URI-scheme model id preservation (also preserved). - #3959 — colon-suffix model id preservation (also preserved). --- api/config.py | 7 +- .../test_issue7290_no_slash_prefixed_alias.py | 259 ++++++++++++++++++ tests/test_model_picker_badges.py | 9 +- 3 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 tests/test_issue7290_no_slash_prefixed_alias.py diff --git a/api/config.py b/api/config.py index 0d7042fa..58afc932 100644 --- a/api/config.py +++ b/api/config.py @@ -6943,8 +6943,8 @@ def _configured_model_badges_from_static_catalog( for entry in configured_entries: provider = entry["provider"] model = entry["model"] - raw_candidates = [] - for candidate in (model, f"{provider}/{model}", f"@{provider}:{model}"): + raw_candidates: list[str] = [] + for candidate in (model, f"@{provider}:{model}"): if candidate and candidate not in raw_candidates: raw_candidates.append(candidate) @@ -8618,10 +8618,9 @@ def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = Fa for entry in configured_entries: provider = entry["provider"] model = entry["model"] - raw_candidates = [] + raw_candidates: list[str] = [] for candidate in ( model, - f"{provider}/{model}", f"@{provider}:{model}", ): if candidate and candidate not in raw_candidates: diff --git a/tests/test_issue7290_no_slash_prefixed_alias.py b/tests/test_issue7290_no_slash_prefixed_alias.py new file mode 100644 index 00000000..b57237b3 --- /dev/null +++ b/tests/test_issue7290_no_slash_prefixed_alias.py @@ -0,0 +1,259 @@ +"""Regression tests for issue #7290 — configured-model badge must not leak a +``{provider}/{model}``-prefixed alias when the native model id itself +contains a slash. + +When the active provider is a built-in plugin provider (e.g. ``commandcode``) +and the configured default model id contains a slash (e.g. +``deepseek/deepseek-v4-flash``), the configured-model badge builder used +to emit three key variants for the same configured identity: + + - the bare native id (``deepseek/deepseek-v4-flash``) + - a slash-prefixed alias (``commandcode/deepseek/deepseek-v4-flash``) + - the ``@provider:model``-prefixed form (``@commandcode:deepseek/deepseek-v4-flash``) + +The slash-prefixed alias was a synthetic key the catalog never actually +exposed — the WebUI dropdown rendered it as a real option, selecting it +sent ``model=commandcode/deepseek/deepseek-v4-flash`` to the agent, and +the provider rejected it with ``HTTP 400: Model ... is not supported on +this endpoint``. The session also persisted the wrong id, so reload +replayed the failure. + +The fix removes the ``{provider}/{model}`` synthetic alias. Bare native +id (which is what the catalog actually exposes) and ``@provider:model`` +(which is the documented prefix form for named custom providers) remain. +Multi-slash, URI-scheme, host:port, and ``@provider:model``-prefixed +model ids all keep working — only the synthesised slash-prefixed alias +disappears. + +The pre-fix test below pins both failure modes reported in the issue: + + 1. the badge map MUST NOT contain the slash-prefixed alias + 2. selecting the bare native id from the dropdown MUST NOT produce a + request whose ``model`` field is the slash-prefixed alias +""" +from __future__ import annotations + +import re +import textwrap +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _build_badges(active_provider, default_model, groups, fallback_providers=None): + """Inline-exec the live ``_build_configured_model_badges`` and return its result. + + ``_norm_model_id`` is inlined as a stand-in (its full body is part of + the production source and is exercised by ``test_norm_model_id_*`` + siblings). Mirrors the existing pattern in + ``test_duplicate_slash_id_primary_badge_sticks_to_matching_provider_only``. + """ + src = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8") + start = src.index("def _build_configured_model_badges() -> dict[str, dict[str, str]]:") + end = src.index(" return badges", start) + len(" return badges") + fn_src = textwrap.dedent(src[start:end]) + + scope = { + "active_provider": active_provider, + "default_model": default_model, + "cfg": {"fallback_providers": fallback_providers or []}, + "groups": groups, + "_resolve_provider_alias": lambda provider: provider, + } + # The live source uses a different `_norm_model_id` than the test's + # inline; either is fine because the badge key is the **raw** candidate, + # not the normalized key, so the raw_candidates list is the surface this + # test pins. + exec( + "def _norm_model_id(model_id):\n" + " s=str(model_id or '').strip().lower()\n" + " if s.startswith('@') and ':' in s: s=s.split(':',1)[1]\n" + " if '/' in s: s=s.split('/',1)[1]\n" + " return s.replace('-', '.')\n", + scope, + ) + exec(fn_src, scope) + return scope["_build_configured_model_badges"]() + + +# --------------------------------------------------------------------------- +# Repro: commandcode + deepseek/deepseek-v4-flash — the literal bug report +# --------------------------------------------------------------------------- + + +def test_commandcode_deepseek_does_not_emit_slash_prefixed_alias() -> None: + """The badge map MUST NOT contain ``commandcode/deepseek/deepseek-v4-flash``.""" + badges = _build_badges( + active_provider="commandcode", + default_model="deepseek/deepseek-v4-flash", + groups=[ + # The catalog exposes the bare native id under the commandcode + # provider — that's the only selectable form. + { + "provider": "CommandCode", + "provider_id": "commandcode", + "models": [{"id": "deepseek/deepseek-v4-flash"}], + } + ], + ) + # The slash-prefixed alias MUST NOT appear in the badge map. + assert "commandcode/deepseek/deepseek-v4-flash" not in badges, ( + f"slash-prefixed alias leaked into the badge map: {badges!r}" + ) + # The bare native id SHOULD be the primary badge (it is the only + # catalog-exposed form and is the correct value to send to the agent). + assert badges.get("deepseek/deepseek-v4-flash", {}).get("role") == "primary" + + +def test_commandcode_deepseek_at_prefixed_alias_still_present() -> None: + """The ``@provider:model`` prefix form MUST still resolve to the primary badge.""" + badges = _build_badges( + active_provider="commandcode", + default_model="deepseek/deepseek-v4-flash", + groups=[ + { + "provider": "CommandCode", + "provider_id": "commandcode", + "models": [{"id": "deepseek/deepseek-v4-flash"}], + } + ], + ) + assert badges.get("@commandcode:deepseek/deepseek-v4-flash", {}).get("role") == "primary", ( + f"@commandcode:deepseek/deepseek-v4-flash alias missing from badges: {badges!r}" + ) + + +# --------------------------------------------------------------------------- +# Dedupe across providers: a slash-bearing id shared by two providers must +# keep the badge only on the matching provider's row, never the other way. +# --------------------------------------------------------------------------- + + +def test_duplicate_slash_id_does_not_pollute_other_provider() -> None: + """Per #7290 contract: deduplicate only when provider+full native model match.""" + badges = _build_badges( + active_provider="custom:alpha", + default_model="vendor/abc-model", + groups=[ + { + "provider": "Alpha", + "provider_id": "custom:alpha", + "models": [{"id": "vendor/abc-model"}], + }, + { + "provider": "Beta", + "provider_id": "custom:beta", + "models": [{"id": "vendor/abc-model"}], + }, + ], + ) + # The bare native id is a real option for BOTH providers; only the + # matching provider's row gets the PRIMARY badge. + payload = badges.get("vendor/abc-model", {}) + assert payload.get("role") == "primary" + assert payload.get("provider") == "custom:alpha" + # The OTHER provider's @-prefixed form is not produced (entry is for alpha + # only); but the alpha @-prefixed form IS produced. + assert "vendor/abc-model" in badges + # No synthetic slash-prefixed alias for the other provider. + assert "custom:beta/vendor/abc-model" not in badges + + +# --------------------------------------------------------------------------- +# Sanity: other forms of model id (URI scheme, host:port, @provider:model) +# must keep working — only the synthetic slash-prefixed alias is removed. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "model_id", + [ + "deepseek-v4-pro", # bare id (no slash) — the common case + "@anthropic:claude-opus-4-7", # @-prefixed named custom provider + "gpt://folder/abc/latest", # URI-scheme id (#3429) + "https://proxy.internal/models/gpt4", # URL id (#3429) + ], +) +def test_non_slash_models_unchanged(model_id: str) -> None: + """No regression: the bare / @-prefixed / URI-scheme id forms are unchanged.""" + badges = _build_badges( + active_provider="commandcode", + default_model=model_id, + groups=[ + { + "provider": "CommandCode", + "provider_id": "commandcode", + "models": [{"id": model_id}], + } + ], + ) + # The bare native id (or its @-prefixed alias) is the primary badge. + assert badges.get(model_id, {}).get("role") == "primary", ( + f"bare id missing primary for {model_id!r}: {badges!r}" + ) + # No synthesised slash-prefixed alias for ANY form. + slash_aliases = [k for k in badges if k.startswith("commandcode/") and k != model_id] + assert not slash_aliases, ( + f"synthesised slash-prefixed aliases leaked: {slash_aliases!r} in {badges!r}" + ) + + +# --------------------------------------------------------------------------- +# Source-shape: the ``{provider}/{model}`` synthetic alias must not appear +# in either configured-badge builder. A silent reversion to the bug +# leaves this regex matchable and the test fails. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "needle", + [ + # _build_configured_model_badges (line ~8618) — 12-space indent + 'f"{provider}/{model}"', + # _configured_model_badges_from_static_catalog (line ~6943) — 8-space + 'f"{provider}/{model}"', + ], +) +def test_synthetic_slash_prefix_alias_is_not_in_source(needle: str) -> None: + """The synthetic ``{provider}/{model}`` alias MUST NOT appear in either builder.""" + src = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8") + assert needle not in src, ( + f"the synthetic slash-prefixed alias leaked back into the source: {needle!r}" + ) + + +def test_raw_candidates_only_holds_bare_and_at_prefix() -> None: + """The raw_candidates tuple MUST contain only the bare native id and the ``@provider:`` alias. + + Catches both the old bug and a future regression that adds back + *any* synthetic prefix form (e.g. ``{provider}::{model}``). + """ + src = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8") + # Find every `for candidate in (` block and verify the contents. + # The 12-space variant is the main builder; the 8-space variant is the + # static-catalog fallback; both must contain only bare + @-prefix. + pattern = re.compile( + r"for candidate in \(([\s\S]*?)\):\s*\n\s+if candidate and candidate not in raw_candidates:", + ) + matches = list(pattern.finditer(src)) + assert matches, "raw_candidates tuple not found in api/config.py" + for m in matches: + body = m.group(1) + # The body may be a single line `model, f"@{provider}:{model}"` or + # a multi-line stack; both are accepted. We only need to verify the + # two safe entries are present and the synthesised one is gone. + bare = re.search(r"\bmodel\b", body) + at_prefixed = re.search( + r'f["\']@\{provider\}:\{model\}["\']', body + ) + assert bare, f"raw_candidates missing the bare `model` entry: {body!r}" + assert at_prefixed, ( + f"raw_candidates missing the @-prefixed entry, or contains a " + f"synthesised prefix form: {body!r}" + ) + # No synthesised prefix form like `{provider}/{model}` survives. + assert "{provider}/{model}" not in body, ( + f"raw_candidates still emits a synthesised slash-prefixed alias: {body!r}" + ) diff --git a/tests/test_model_picker_badges.py b/tests/test_model_picker_badges.py index b14ec5c8..40400579 100644 --- a/tests/test_model_picker_badges.py +++ b/tests/test_model_picker_badges.py @@ -47,8 +47,13 @@ def test_available_models_exposes_primary_and_fallback_badges(): assert badges.get("@openai-codex:gpt-5.4", {}).get("label") == "Primary" assert badges.get("@copilot:gpt-4.1", {}).get("role") == "fallback" assert badges.get("@copilot:gpt-4.1", {}).get("label") == "Fallback 1" - assert badges.get("anthropic/claude-haiku-4.5", {}).get("role") == "fallback" - assert badges.get("anthropic/claude-haiku-4.5", {}).get("label") == "Fallback 2" + # #7290: the synthesised ``{provider}/{model}`` alias is gone — the + # native bare id and the ``@provider:model`` prefix are the only + # keys a configured entry can publish. + assert badges.get("claude-haiku-4.5", {}).get("role") == "fallback" + assert badges.get("@anthropic:claude-haiku-4.5", {}).get("role") == "fallback" + assert "anthropic/claude-haiku-4.5" not in badges + assert badges.get("@anthropic:claude-haiku-4.5", {}).get("label") == "Fallback 2" def test_duplicate_slash_id_primary_badge_sticks_to_matching_provider_only(): From 0f2c4af09faed538daba62437b2e51392754a056 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 22 Sep 2026 10:02:23 +0000 Subject: [PATCH 29/47] docs(changelog): stamp #7709 slash-alias badge fix --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7725c950..6682906d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ kept for passwordless-passkey instances where it is the only login affordance. Thanks @happy5318. (#7056, #7715) +- **A model whose native id contains a slash no longer poisons its configured-model badge.** + The badge builder synthesised a `{provider}/{model}` alias alongside the bare id, so a + model already carrying a slash (for example `commandcode` + `deepseek/deepseek-v4-flash`) + produced `commandcode/deepseek/deepseek-v4-flash` — a non-functional id that leaked into + the badge map, persisted into session state and returned `HTTP 400` from the agent. Only + the bare id and the `@provider:model` form are emitted now. Thanks @happy5318. (#7290, #7709) + - **A conversation whose history carries an explicit `null` tool-call list no longer breaks the tool-call summary.** `_extract_tool_calls_from_messages` used `.get('tool_calls', [])`, which returns the default only when the key is *absent* — when a stored message carried the From d698fb47724015544a93945ef041cfc6a94d8fc7 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Tue, 22 Sep 2026 16:19:17 +0800 Subject: [PATCH 30/47] fix(routes): mirror the detail-load endpoint's 409 contract in every profile-mismatch check (#7710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path The detail-load endpoint (and the foreign-session synthesizer) already distinguish "session owned by a KNOWN other profile" (return ``409 session_profile_mismatch`` so the client can offer to switch to it per #5419) from "session missing/legacy with no profile stamped" (return ``404 Session not found`` so the frontend's self-heal clears the stale URL + localStorage). The generic request-guard ``_session_id_visible_to_request_profile`` flattened both cases to a ``404 Session not found``, so any cross-profile POST/PATCH/DELETE (archive, rename, pin, delete, session-attach, anchor-scene, …) and the stream-related status/cancel/replay paths failed with a misleading "Session not found" instead of the actionable 409 the detail endpoint emits. The reporter's wire evidence: ``` POST /api/session/archive {"session_id":"","archived":true} -> 404 {"error": "Session not found"} GET /api/session?session_id= -> 409 {"error": "Session belongs to a different profile", "code": "session_profile_mismatch", "session_id": "", "profile": "def"} ``` Same session id, same moment: the detail endpoint reports a profile mismatch, archive masks it as 404. The fix mirrors the detail endpoint's contract in the generic guard and in every other inline check that previously went to ``bad(handler, "Session not found", 404)`` for a known-other-profile session. ## What Changed 1. ``api/routes.py:595`` — ``_session_id_visible_to_request_profile`` now emits ``409 session_profile_mismatch`` for a session owned by a known other profile, and keeps ``404 Session not found`` only for the None-profile (unknown/legacy) case so the frontend self-heal still fires for actually-missing sids. 2. ``api/routes.py:5348`` (anchor-scene persist), ``:23994`` (compression-recovery start), ``:24385`` (chat/start with the inline placeholder-retag rules preserved), ``:28621`` (session import_cli's already-imported branch) — the same 409-vs-404 split, with the existing branches kept intact (the chat/start retag path, the import_cli ``allow_all_profiles`` short circuit). 3. The ``_stream_id_visible_to_request_profile`` wrapper already delegates to ``_session_id_visible_to_request_profile`` for the owner-session check, so the stream status / cancel / replay endpoints get the new contract for free. ## Why It Matters A user picking the configured default model in the dropdown sees two visually identical entries for the same model id, picks the wrong "prefixed" one (the one matching the dropdown's order), the agent returns 400, and the session state is corrupted with a non-functional model id until the user manually clears it. The dropdown's own deduplication already handles `@provider:model` correctly via the documented prefix form; only the synthesised ``{provider}/{model}`` form was wrong. The cross-profile write paths (anchor-scene persist, compression-recovery start, chat/start, session import) all had the same gap: a successful auth under profile A could persist or recover state against a session owned by profile B, surfacing a 404 to profile A's user that reads like "the session is gone" — but the session is in fact intact, just owned by another profile. The 409 contract is what closes the gap: the client gets a structured signal it can act on, and the session state stays consistent. ## Verification - ``tests/test_issue7710_cross_profile_404_to_409.py`` (new, 6 tests): - 5 behavioural: profile-mismatch path returns 409 with the ``session_profile_mismatch`` payload, the None-profile path keeps 404 for the frontend self-heal, visible session passes silently, missing session passes silently, ``emit_error=False`` suppresses the response. - 1 source-shape: the helper body MUST contain a 409 branch with the ``session_profile_mismatch`` code; a silent reversion to the single-line ``bad(..., 404)`` fails the suite. - ``tests/test_issue4812_session_sse_stream.py`` — 1 assertion updated to the new 409 payload (the SSE route walks through the generic guard). - ``tests/test_session_active_profile_authorization.py`` — 7 assertions updated to the 409 payload (duplicate, file, chat/start foreign, chat/stream/status, chat/stream/cancel, chat/stream replay). Stream tests use ``foreign_session`` (the owner session id), NOT the stream id, because ``_stream_id_visible_to_request_profile`` delegates to the owner-session check. - ``tests/test_anchor_scene_persistence.py`` — 1 assertion updated to the 409 payload. The 3 ``Assistant message not found`` 404 cases (lines 454/561/611) are kept as-is — they're not profile mismatches, they're missing-message 404s. - ``tests/test_issue4067_import_cli_cross_profile_guard.py`` — 1 assertion updated to the 409 payload, plus the ``_capture`` mock signature updated to accept the ``status`` keyword that ``j(handler, payload, status=200, ...)`` already passes. Other tests in the existing suite: **161 passed across 8 related test files** (session active-profile authorization, anchor scene, issue 4067, issue 4812 SSE, issue 7710 new, chat start claim, workspace stale recovery, issue 5420 profile switch). ## Contract Change The following routes now return ``409 session_profile_mismatch`` (``{error, code, session_id, profile}``) for a session owned by a known other profile, where they previously returned ``404 Session not found``: - ``POST /api/session/duplicate`` (now also goes through ``_guard_request_session_visibility`` for the body session id) - ``GET /api/file?session_id=…`` - ``POST /api/chat/start`` (the inline profile check after ``_get_or_materialize_session``) - ``GET /api/chat/stream/status?stream_id=…`` - ``GET /api/chat/stream/cancel?stream_id=…`` - ``GET /api/chat/stream?stream_id=…`` (the dead-stream replay branch) - ``POST /api/session/anchor-scene`` - ``POST /api/session/compression-recovery/start`` - ``POST /api/session/import_cli`` (the already-imported branch when ``allow_all_profiles`` is False) The ``404 Session not found`` self-heal path is preserved for the None-profile (unknown/legacy) case across all of these routes, so the frontend's existing stale-URL/localStorage cleanup behaviour is unchanged for actually-missing sessions. ## Risks - A consumer that previously relied on the 404 to detect "this session belongs to another profile" will now see a 409 with a ``code`` field. The detail-load endpoint has emitted this exact contract since #5419, and the frontend already handles it (offers to switch to the owning profile). The cross-profile write paths now match the detail-load contract; the frontend's already-shipped switcher UI handles both surfaces consistently. - The anchor-scene / compression-recovery / chat-start tests that were updated are a small set (5 total); the existing ``Assistant message not found`` 404 paths in ``test_anchor_scene_persistence`` are kept untouched (those are not profile mismatches). ## Related - #7710 — original issue (elkpvl-commits, 2026-09-22) with the full repro and the suggested fix shape. - #7549 — the underlying cross-profile archive bug, already tracked by @webtecnica in #7559 (which is the "business-level" fix: row-profile carry + frontend sends profile + materialize into the row's own profile). This PR is the orthogonal error-code-level fix: the request-guard's 404-vs-409 split. - #5419 — the original auto-switch-profiles-for-session-links feature, whose frontend switcher UI consumes the ``session_profile_mismatch`` code on the 409. --- api/routes.py | 76 +++++++- tests/test_anchor_scene_persistence.py | 14 +- ...ssue4067_import_cli_cross_profile_guard.py | 22 ++- tests/test_issue4812_session_sse_stream.py | 13 +- ...test_issue7710_cross_profile_404_to_409.py | 176 ++++++++++++++++++ ...st_session_active_profile_authorization.py | 70 ++++++- 6 files changed, 353 insertions(+), 18 deletions(-) create mode 100644 tests/test_issue7710_cross_profile_404_to_409.py diff --git a/api/routes.py b/api/routes.py index 2e716b36..d5b4a891 100644 --- a/api/routes.py +++ b/api/routes.py @@ -593,7 +593,15 @@ def _request_session_visibility_exempt(method: str, path: str | None) -> bool: def _session_id_visible_to_request_profile(handler, sid, *, emit_error: bool = True) -> bool: - """Return whether ``sid`` belongs to the active profile.""" + """Return whether ``sid`` belongs to the active profile. + + On a profile mismatch, the helper mirrors the detail-load endpoint's + contract (#13043, #13493): return ``409 session_profile_mismatch`` for + a session owned by a KNOWN other profile, and keep ``404 Session + not found`` only for the unknown/legacy None-profile case so the + frontend's self-heal (clear stale URL + localStorage) keeps firing + for actually-missing sids. ``#7710``. + """ if not isinstance(sid, str) or not sid: return True if not is_safe_session_id(sid): @@ -602,9 +610,23 @@ def _session_id_visible_to_request_profile(handler, sid, *, emit_error: bool = T session = get_session(sid, metadata_only=True) except KeyError: return True - if not _session_visible_to_active_profile(getattr(session, "profile", None), handler): + session_profile = getattr(session, "profile", None) or None + if not _session_visible_to_active_profile(session_profile, handler): if emit_error: - bad(handler, "Session not found", 404) + if session_profile: + j(handler, { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": sid, + "profile": session_profile, + }, status=409) + else: + # Unknown/legacy None-profile sidecar: keep the 404 so the + # frontend's self-heal still fires. _profiles_match coerces + # None->'default', so a truly missing/legacy session under a + # non-default active profile would otherwise emit a useless + # 409 with profile=null. + bad(handler, "Session not found", 404) return False return True @@ -5323,7 +5345,21 @@ def _handle_session_anchor_scene(handler, body): # this an authenticated request under profile A could persist anchor scenes # onto a session owned by profile B (cross-profile write). Reject as 404 — # same shape the read path uses — and leave anchor_activity_scenes untouched. - if not _session_visible_to_active_profile(getattr(s, "profile", None) or None, handler): + # #7710: cross-profile writes are rejected with 409 + # ``session_profile_mismatch`` so the client can offer to switch + # to the owning profile (mirrors the detail-load endpoint's + # contract at #13043 / #13493). 404 is preserved for the + # None-profile (unknown/legacy) case so the frontend self-heal + # path still fires for actually-missing sids. + _anchor_session_profile = getattr(s, "profile", None) or None + if not _session_visible_to_active_profile(_anchor_session_profile, handler): + if _anchor_session_profile: + return j(handler, { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": sid, + "profile": _anchor_session_profile, + }, status=409) return bad(handler, "Session not found", 404) with _get_session_agent_lock(sid): idx, message = _find_anchor_scene_message( @@ -23995,6 +24031,17 @@ def _handle_session_compression_recovery_start(handler, body): except KeyError: return bad(handler, "Session not found", 404) if not _session_visible_to_active_profile(getattr(source, "profile", None), handler): + # #7710: same contract as the detail-load endpoint — 409 + # ``session_profile_mismatch`` for a known other profile, + # 404 only for the None-profile self-heal path. + _recovery_session_profile = getattr(source, "profile", None) + if _recovery_session_profile: + return j(handler, { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": sid, + "profile": _recovery_session_profile, + }, status=409) return bad(handler, "Session not found", 404) recovery = compression_recovery_payload_for_session(source) if not recovery: @@ -24394,6 +24441,17 @@ def _handle_chat_start(handler, body, diag=None): # Empty placeholders can still be retagged when the # requested profile matches the active request profile. s.profile = requested_profile + elif session_profile: + # #7710: known other profile → 409 ``session_profile_mismatch`` + # so the client can offer to switch to it (#5419). + # 404 is preserved only for the None-profile + # (unknown/legacy) self-heal case. + return j(handler, { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": body.get("session_id", ""), + "profile": session_profile, + }, status=409) else: return bad(handler, "Session not found", 404) # Resolve durable rotations before any workspace/model/pending mutation. @@ -28622,6 +28680,16 @@ def _handle_session_import_cli(handler, body): if requested_profile and not _profiles_match(existing_profile, requested_profile): return bad(handler, "Session not found in CLI store", 404) elif not _session_visible_to_active_profile(existing_profile, handler): + # #7710: same contract as the detail-load endpoint — + # 409 ``session_profile_mismatch`` for a known other + # profile, 404 only for the None-profile self-heal path. + if existing_profile: + return j(handler, { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": sid, + "profile": existing_profile, + }, status=409) return bad(handler, "Session not found in CLI store", 404) refresh_profile = requested_profile or existing_profile cli_meta = _resolve_cli_import_metadata( diff --git a/tests/test_anchor_scene_persistence.py b/tests/test_anchor_scene_persistence.py index fcd2a28f..c6ff0055 100644 --- a/tests/test_anchor_scene_persistence.py +++ b/tests/test_anchor_scene_persistence.py @@ -241,8 +241,18 @@ def test_anchor_scene_persistence_rejects_cross_profile_write(tmp_path, monkeypa SimpleNamespace(command="POST"), SimpleNamespace(path="/api/session/anchor-scene"), ) is True - assert captured.get("status") == 404 - assert "payload" not in captured # success j() never called + # #7710: the generic request-guard now mirrors the detail-load + # endpoint's contract — a session owned by a KNOWN other profile + # yields 409 ``session_profile_mismatch`` so the client can offer + # to switch to it (#5419). The 404 self-heal path is preserved for + # the None-profile (unknown/legacy) case. + assert captured.get("status") == 409, captured + assert captured.get("payload") == { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": "foreignprofile1", + "profile": "profile-b", + } raw = json.loads((session_dir / "foreignprofile1.json").read_text(encoding="utf-8")) assert not raw.get("anchor_activity_scenes"), ( "cross-profile request must NOT persist anchor_activity_scenes" diff --git a/tests/test_issue4067_import_cli_cross_profile_guard.py b/tests/test_issue4067_import_cli_cross_profile_guard.py index 669604ef..d216b060 100644 --- a/tests/test_issue4067_import_cli_cross_profile_guard.py +++ b/tests/test_issue4067_import_cli_cross_profile_guard.py @@ -41,7 +41,13 @@ class _FakeHandler: def _capture(monkeypatch): cap = {} - monkeypatch.setattr(routes, "j", lambda h, o: (cap.__setitem__("ok", o), True)[1]) + # #7710: the real ``j`` signature is ``j(handler, payload, status=200, + # extra_headers=None, *, pretty=True)``; the mock has to accept the + # status keyword so the helper's 409 ``session_profile_mismatch`` + # payload can be captured here. + monkeypatch.setattr( + routes, "j", lambda h, o, *_, **__: (cap.__setitem__("ok", o), True)[1] + ) monkeypatch.setattr( routes, "bad", @@ -72,7 +78,10 @@ class _FakeSession: def test_import_cli_existing_foreign_profile_unqualified_request_404(monkeypatch): """Unqualified request (active profile=default) for a session stored under a - foreign profile must 404 — not read or refresh the foreign session.""" + foreign profile must yield 409 ``session_profile_mismatch`` (so the client + can offer to switch to it per #5419) — not 404, which would mask a + legitimate owned-by-other-profile session as missing and trigger a + destructive self-heal in the frontend (#7710).""" foreign = _FakeSession("foreign_existing_001", "other") monkeypatch.setattr(routes.Session, "load", staticmethod(lambda sid: foreign)) monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default") @@ -88,8 +97,13 @@ def test_import_cli_existing_foreign_profile_unqualified_request_404(monkeypatch {"session_id": "foreign_existing_001"}, ) - assert "bad" in cap, f"expected 404, got {cap}" - assert cap["bad"][1] == 404 + assert "ok" in cap, f"expected 409 session_profile_mismatch, got {cap}" + assert cap["ok"] == { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": "foreign_existing_001", + "profile": "other", + } def test_import_cli_existing_same_profile_still_refreshes(monkeypatch): diff --git a/tests/test_issue4812_session_sse_stream.py b/tests/test_issue4812_session_sse_stream.py index 27f9c771..8746df73 100644 --- a/tests/test_issue4812_session_sse_stream.py +++ b/tests/test_issue4812_session_sse_stream.py @@ -507,7 +507,18 @@ def test_session_route_blocks_hidden_sessions_before_replay_or_live_attach(monke handler = _FakeHandler() routes.handle_get(handler, urlparse("/api/sessions/hidden_session/events")) - assert cap["bad"] == ("Session not found", 404) + # #7710: the generic request-guard now mirrors the detail-load + # endpoint's contract — a session owned by a KNOWN other profile + # yields 409 ``session_profile_mismatch`` so the client can offer + # to switch to it (#5419). The 404 self-heal path is preserved for + # the None-profile (unknown/legacy) case. + assert cap.get("status") == 409, cap + assert cap.get("ok") == { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": "hidden_session", + "profile": "other", + } def test_session_route_live_delivery_skips_replayed_active_run_items(monkeypatch): diff --git a/tests/test_issue7710_cross_profile_404_to_409.py b/tests/test_issue7710_cross_profile_404_to_409.py new file mode 100644 index 00000000..73fbc492 --- /dev/null +++ b/tests/test_issue7710_cross_profile_404_to_409.py @@ -0,0 +1,176 @@ +"""Regression tests for issue #7710 — cross-profile session guards return 409 + ``session_profile_mismatch`` instead of masking a known-other-profile session as 404. + +The detail-load endpoint (and the foreign-session synthesizer) already +distinguish "session owned by a KNOWN other profile" (return ``409 +session_profile_mismatch``) from "session missing/legacy with no +profile stamped" (return ``404 Session not found`` so the frontend +self-heal clears the stale URL). + +The generic request-guard +``_session_id_visible_to_request_profile`` (used by +``_guard_request_session_visibility``) flattened both cases to a +``404 Session not found``, so any cross-profile POST/PATCH/DELETE +(archive, rename, pin, delete, …) failed with a misleading "Session +not found" instead of the actionable 409 the detail endpoint emits. +The fix mirrors the detail endpoint's contract in the generic guard. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _read_helper_body() -> str: + """Extract ``_session_id_visible_to_request_profile`` as importable text.""" + import textwrap + src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8") + start = src.index("def _session_id_visible_to_request_profile(") + end_marker = "def _stream_id_owner_session_id" + end = src.index(end_marker, start) + return textwrap.dedent(src[start:end]) + + +def _exec_helper(scope: dict) -> None: + """Execute the helper body inside ``scope``.""" + body = _read_helper_body() + exec(body, scope) + + +# --------------------------------------------------------------------------- +# Profile-mismatch path: known other profile → 409 + session_profile_mismatch +# --------------------------------------------------------------------------- + + +class _FakeSession: + def __init__(self, profile: str | None) -> None: + self.profile = profile + + +class _FakeHandler: + """Minimal stand-in for the routes handler the helper writes through.""" + + def __init__(self) -> None: + self.writes: list[tuple[int, dict | str | None]] = [] + + +@pytest.fixture +def helper_scope(): + """Build an exec scope with the helper's dependencies stubbed. + + ``get_session`` is the only thing the helper actually calls into + (besides the visibility predicate which is itself inlined). The + visibility predicate is monkey-patched per-test so we can drive both + the "match" and "mismatch" branches deterministically. + """ + scope: dict = { + # j / bad — the helper writes through these. Record instead of + # emit so the test can assert status + body. + "j": lambda handler, payload, status=200, **_: handler.writes.append((status, payload)), + "bad": lambda handler, msg, status=400: handler.writes.append((status, msg)), + # get_session is required so the helper can resolve the row. + "get_session": lambda sid, metadata_only=True: scope["_resolve"](sid), + # is_safe_session_id — accept anything that looks like a sid. + "is_safe_session_id": lambda sid: bool(sid) and len(str(sid)) < 256, + } + return scope + + +def test_profile_mismatch_returns_409_with_code_and_profile(helper_scope) -> None: + """A session owned by a known other profile MUST yield 409 ``session_profile_mismatch``.""" + handler = _FakeHandler() + helper_scope["_resolve"] = lambda sid: _FakeSession(profile="alpha") + # Visibility predicate: returns False (profile mismatch). + helper_scope["_session_visible_to_active_profile"] = lambda session_profile, _h: False + + _exec_helper(helper_scope) + fn = helper_scope["_session_id_visible_to_request_profile"] + assert fn(handler, "sess-1") is False + assert len(handler.writes) == 1, handler.writes + status, body = handler.writes[0] + assert status == 409, body + assert body["code"] == "session_profile_mismatch" + assert body["session_id"] == "sess-1" + assert body["profile"] == "alpha" + assert "different profile" in body["error"].lower() + + +def test_unknown_profile_returns_404_for_frontend_self_heal(helper_scope) -> None: + """A session with ``profile=None`` MUST keep 404 so the frontend's self-heal fires.""" + handler = _FakeHandler() + helper_scope["_resolve"] = lambda sid: _FakeSession(profile=None) + helper_scope["_session_visible_to_active_profile"] = lambda session_profile, _h: False + + _exec_helper(helper_scope) + fn = helper_scope["_session_id_visible_to_request_profile"] + assert fn(handler, "sess-1") is False + assert len(handler.writes) == 1, handler.writes + status, msg = handler.writes[0] + assert status == 404 + assert msg == "Session not found" + + +def test_visible_session_returns_no_error(helper_scope) -> None: + """A session owned by the active profile MUST pass the guard silently.""" + handler = _FakeHandler() + helper_scope["_resolve"] = lambda sid: _FakeSession(profile="default") + helper_scope["_session_visible_to_active_profile"] = lambda session_profile, _h: True + + _exec_helper(helper_scope) + fn = helper_scope["_session_id_visible_to_request_profile"] + assert fn(handler, "sess-1") is True + assert handler.writes == [] + + +def test_missing_session_returns_no_error(helper_scope) -> None: + """A session id that get_session cannot resolve MUST pass the guard silently (the route will 404 later).""" + handler = _FakeHandler() + helper_scope["_resolve"] = lambda sid: (_ for _ in ()).throw(KeyError(sid)) + + _exec_helper(helper_scope) + fn = helper_scope["_session_id_visible_to_request_profile"] + assert fn(handler, "sess-1") is True + assert handler.writes == [] + + +def test_emit_error_false_suppresses_response(helper_scope) -> None: + """``emit_error=False`` MUST return False without writing any response (used by the guard's exemption probe).""" + handler = _FakeHandler() + helper_scope["_resolve"] = lambda sid: _FakeSession(profile="alpha") + helper_scope["_session_visible_to_active_profile"] = lambda session_profile, _h: False + + _exec_helper(helper_scope) + fn = helper_scope["_session_id_visible_to_request_profile"] + assert fn(handler, "sess-1", emit_error=False) is False + assert handler.writes == [] + + +# --------------------------------------------------------------------------- +# Source-shape: the synthesised 409 payload MUST keep the four contract +# fields and the status code, in the helper. A silent reversion to the +# original single-line ``bad(..., 404)`` fails the suite. +# --------------------------------------------------------------------------- + + +def test_source_emits_409_not_404_in_helper() -> None: + """The helper body MUST contain a 409 ``session_profile_mismatch`` branch. + + Catches a future regression that folds the two cases back together + (or drops the 409 status code in favour of 404). The 404 case is + still preserved for the None-profile self-heal path, but only as a + fall-through inside the same block — not as the sole outcome. + """ + body = _read_helper_body() + assert "status=409" in body, ( + "the helper no longer emits 409 for a known-other-profile session" + ) + assert "session_profile_mismatch" in body, ( + "the helper no longer carries the ``session_profile_mismatch`` code" + ) + assert "Session not found" in body, ( + "the None-profile 404 self-heal path was dropped — " + "keep the 404 for the None-profile branch so the frontend " + "self-heal still fires for actually-missing sids" + ) diff --git a/tests/test_session_active_profile_authorization.py b/tests/test_session_active_profile_authorization.py index 83d13d42..833e2f11 100644 --- a/tests/test_session_active_profile_authorization.py +++ b/tests/test_session_active_profile_authorization.py @@ -108,7 +108,17 @@ def test_session_duplicate_foreign_profile_session_blocked_by_visibility_guard(m cap = _capture(monkeypatch) routes.handle_post(handler, urlparse("/api/session/duplicate")) - assert cap["bad"] == ("Session not found", 404) + # #7710: the generic request-guard mirrors the detail-load endpoint's + # contract — a session owned by a KNOWN other profile yields + # 409 ``session_profile_mismatch`` so the client can offer to + # switch to it (#5419). The 404 self-heal path is preserved for + # the None-profile (unknown/legacy) case. + assert cap["ok"] == { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": "foreign_duplicate", + "profile": "other", + } def test_session_duplicate_same_profile_still_duplicates(monkeypatch): @@ -151,7 +161,13 @@ def test_file_read_foreign_profile_session_returns_404_before_file_ops(monkeypat routes.handle_get(handler, urlparse("/api/file?session_id=foreign_file&path=notes.txt")) - assert cap["bad"] == ("Session not found", 404) + # #7710: see line 111 above — same generic-guard contract. + assert cap["ok"] == { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": "foreign_file", + "profile": "other", + } def test_chat_start_foreign_persisted_session_returns_404_before_start_run(monkeypatch): @@ -171,7 +187,13 @@ def test_chat_start_foreign_persisted_session_returns_404_before_start_run(monke cap = _capture(monkeypatch) routes.handle_post(handler, urlparse("/api/chat/start")) - assert cap["bad"] == ("Session not found", 404) + # #7710: see line 111 above — same generic-guard contract. + assert cap["ok"] == { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": "chat_foreign", + "profile": "other", + } def test_chat_start_body_profile_cannot_retag_visible_empty_session_without_active_profile(monkeypatch): @@ -287,7 +309,13 @@ def test_chat_stream_status_blocks_foreign_active_stream(monkeypatch): config.ACTIVE_RUNS.clear() config.ACTIVE_RUNS.update(previous) - assert cap["bad"] == ("Session not found", 404) + # #7710: see line 111 above — same generic-guard contract. + assert cap["ok"] == { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": "foreign_session", + "profile": "other", + } def test_chat_stream_status_blocks_foreign_registered_stream_before_worker_start(monkeypatch): @@ -328,7 +356,15 @@ def test_chat_stream_status_blocks_foreign_registered_stream_before_worker_start config.STREAM_SESSION_OWNERS.clear() config.STREAM_SESSION_OWNERS.update(previous_owners) - assert cap["bad"] == ("Session not found", 404) + # #7710: see line 111 above — same generic-guard contract. + # The helper resolves the stream to its OWNER session id (not the + # stream id), so the mismatch payload carries the owner session id. + assert cap["ok"] == { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": "foreign_session", + "profile": "other", + } def test_chat_stream_status_keeps_same_profile_stream_visible(monkeypatch): @@ -392,7 +428,17 @@ def test_chat_cancel_blocks_foreign_owned_stream_before_cancel_call(monkeypatch) config.ACTIVE_RUNS.update(previous) assert calls["cancel"] == 0 - assert cap["bad"] == ("Session not found", 404) + # #7710: see line 111 above — same generic-guard contract. + # The helper resolves the stream to its OWNER session id (not the + # stream id), so the mismatch payload carries the owner session id. + # This test's stream is owned by ``foreign_session`` (see the + # ACTIVE_RUNS fixture above). + assert cap["ok"] == { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": "foreign_session", + "profile": "other", + } def test_chat_cancel_same_profile_stream_still_passes_through(monkeypatch): @@ -443,7 +489,17 @@ def test_chat_stream_blocks_foreign_owned_dead_stream_before_replay(monkeypatch) cap = _capture(monkeypatch) routes.handle_get(handler, urlparse("/api/chat/stream?stream_id=stream-dead-foreign")) - assert cap["bad"] == ("Session not found", 404) + # #7710: see line 111 above — same generic-guard contract. + # The helper resolves the stream to its OWNER session id (not the + # stream id), so the mismatch payload carries the owner session id. + # This test's stream is owned by ``foreign_session`` (see the + # ``find_run_summary`` mock above). + assert cap["ok"] == { + "error": "Session belongs to a different profile", + "code": "session_profile_mismatch", + "session_id": "foreign_session", + "profile": "other", + } def test_chat_stream_allows_unknown_dead_stream_fallback_replay_path(monkeypatch): From 0378ed4e34dbb30ef2efd733f5dee0a3f4641aea Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 22 Sep 2026 09:57:26 +0000 Subject: [PATCH 31/47] fix(routes): preserve live cards on cross-profile 409 collisions Gate follow-up for #7710. Three front-end handlers treated EVERY 409 as a domain-specific stale/expired signal, so the new session_profile_mismatch response would have: - messages.js: hidden a LIVE clarify card and mislabelled it expired - ui.js: retired a LIVE compression-recovery card with a false note - commands.js: rendered a false 'Compression failed' error state Each now routes through _sessionProfileMismatchFromError() first and leaves the card standing. Also captures the status in the import_cli test mock so a wrong status can no longer pass (the mock swallowed it into *_). --- static/commands.js | 6 ++++++ static/messages.js | 13 +++++++++++++ static/ui.js | 10 ++++++++++ ...test_issue4067_import_cli_cross_profile_guard.py | 13 +++++++++---- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/static/commands.js b/static/commands.js index fa787e24..da741566 100644 --- a/static/commands.js +++ b/static/commands.js @@ -991,6 +991,12 @@ async function resumeManualCompressionForSession(sid){ // No active compression job or transient server error — not a real failure. // 404: route missed or session gone; 5xx: backend exception during status check. if(e&&(!e.status||e.status===404||e.status>=500)) return; + // #7710: a cross-profile refusal now arrives as 409 + // (``session_profile_mismatch``) where it used to be a 404 that hit the + // benign early-return above. It is not a compression failure, so do not + // render the error state or locally settle the compression UI. + if(e&&e.status===409&&typeof _sessionProfileMismatchFromError==='function' + &&_sessionProfileMismatchFromError(e)) return; if(S.session&&S.session.session_id===sid&&typeof setCompressionUi==='function'){ const visibleMessages=_manualCompressionVisibleMessages(); setCompressionUi({ diff --git a/static/messages.js b/static/messages.js index e7bcb73c..078e6035 100644 --- a/static/messages.js +++ b/static/messages.js @@ -8988,6 +8988,19 @@ async function respondClarify(response) { // not tear B down on A's late 409. The SSE/poll path will re-render the // next prompt's card from scratch via ``showClarifyCard`` either way. if (e && e.status === 409) { + // #7710: a cross-profile refusal now also arrives as 409 + // (``session_profile_mismatch``). The prompt is NOT expired — the write + // was refused because the session belongs to another profile. Treating + // it as expired would hide a live clarification card and mislabel the + // cause, so leave the card standing and report the real reason. + if (typeof _sessionProfileMismatchFromError === 'function' + && _sessionProfileMismatchFromError(e)) { + _clarifySetControlsDisabled(false, false); + if (typeof setStatus === "function") { + setStatus("Clarify: session belongs to a different profile"); + } + return; + } if (_clarifyId === clarifyId) { // Same card still showing — dismiss it and rescue the typed draft. // Order matters: ``_stashClarifyDraft`` (called from diff --git a/static/ui.js b/static/ui.js index 37742c5a..675bd66d 100644 --- a/static/ui.js +++ b/static/ui.js @@ -508,6 +508,16 @@ async function startCompressionRecovery(btn){ const composer=$('msg'); if(composer&&typeof composer.focus==='function') composer.focus(); }catch(e){ + // #7710: a cross-profile refusal now also arrives as 409 + // (``session_profile_mismatch``). That is NOT a stale recovery action — + // the card is still valid, the request was simply refused because the + // session belongs to another profile. Retiring it would hide a live card + // and show a false "conversation already moved on" note. + if(e&&e.status===409&&typeof _sessionProfileMismatchFromError==='function' + &&_sessionProfileMismatchFromError(e)){ + if(typeof setStatus==='function') setStatus('Session belongs to a different profile'); + return; + } // A 409 means this session no longer has an active recovery action (the // session already moved on — e.g. a substantive prompt cleared it). The // persisted card in the transcript is stale, so retire it and show a neutral diff --git a/tests/test_issue4067_import_cli_cross_profile_guard.py b/tests/test_issue4067_import_cli_cross_profile_guard.py index d216b060..42274c32 100644 --- a/tests/test_issue4067_import_cli_cross_profile_guard.py +++ b/tests/test_issue4067_import_cli_cross_profile_guard.py @@ -44,10 +44,14 @@ def _capture(monkeypatch): # #7710: the real ``j`` signature is ``j(handler, payload, status=200, # extra_headers=None, *, pretty=True)``; the mock has to accept the # status keyword so the helper's 409 ``session_profile_mismatch`` - # payload can be captured here. - monkeypatch.setattr( - routes, "j", lambda h, o, *_, **__: (cap.__setitem__("ok", o), True)[1] - ) + # payload can be captured here. Capture the STATUS too — swallowing it + # into ``*_`` would let a wrong status (200/404) pass this test. + def _fake_j(h, o, status=200, *_, **__): + cap["ok"] = o + cap["ok_status"] = status + return True + + monkeypatch.setattr(routes, "j", _fake_j) monkeypatch.setattr( routes, "bad", @@ -98,6 +102,7 @@ def test_import_cli_existing_foreign_profile_unqualified_request_404(monkeypatch ) assert "ok" in cap, f"expected 409 session_profile_mismatch, got {cap}" + assert cap["ok_status"] == 409, f"expected status 409, got {cap.get('ok_status')}" assert cap["ok"] == { "error": "Session belongs to a different profile", "code": "session_profile_mismatch", From b7bae04c3b88d83e917055057a2c10e68d0e793c Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 22 Sep 2026 10:21:55 +0000 Subject: [PATCH 32/47] docs(changelog): stamp #7714 cross-profile 409 contract --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6682906d..8b97d737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ ### Fixed +- **Opening a session that belongs to another profile now offers to switch to it instead of + looking deleted.** Several cross-profile guards answered `404 Session not found`, which the + front-end treats as a missing session and self-heals by clearing the URL and local storage — + so a valid deep link into another profile's session destroyed its own way back. Those guards + now return the same `409 session_profile_mismatch` the detail-load endpoint has used since + #5419, while a genuinely missing session still 404s and still self-heals. The clarify card, + compression-recovery card and manual-compression flow were each treating *every* `409` as + their own "stale" signal and are now scoped so a cross-profile refusal no longer hides a live + prompt or reports a false compression failure. Thanks @happy5318. (#7710, #7714) + - **An OIDC-only or passkey-only deployment no longer shows a dead password prompt.** The `/login` page rendered the password input, submit button and passkey control unconditionally, so an instance with native OIDC configured and no From 20f6a6862642b9914b4eea7d5bf2b4c7f07557fa Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Fri, 31 Jul 2026 18:12:49 -0300 Subject: [PATCH 33/47] fix: close rejected HTTP writes before reuse --- api/routes.py | 4 ++ server.py | 7 ++- tests/test_rejected_write_connection_close.py | 55 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tests/test_rejected_write_connection_close.py diff --git a/api/routes.py b/api/routes.py index d5b4a891..a7d01953 100644 --- a/api/routes.py +++ b/api/routes.py @@ -5871,6 +5871,9 @@ def _csrf_rejection_error(handler) -> str: def _check_csrf(handler) -> bool: """Reject cross-origin or tokenless authenticated browser unsafe requests.""" if not _check_same_origin_browser_request(handler): + # CSRF checks run before read_body(). Close rather than reusing an + # HTTP/1.1 connection whose unread body would corrupt the next request. + handler.close_connection = True return False if not _is_browser_unsafe_request(handler): return True # non-browser clients (curl, MCP, agent) have no Origin/Referer @@ -5883,6 +5886,7 @@ def _check_csrf(handler) -> bool: submitted = handler.headers.get(CSRF_HEADER_NAME) or handler.headers.get("X-CSRF-Token") if verify_csrf_token(cookie_val or "", submitted or ""): return True + handler.close_connection = True return _set_csrf_failure_reason(handler, "token_mismatch") diff --git a/server.py b/server.py index 040d80c1..8205167b 100644 --- a/server.py +++ b/server.py @@ -404,7 +404,12 @@ class Handler(BaseHTTPRequestHandler): _is_csp_report_post = ( parsed.path == "/api/csp-report" and self.command == "POST" ) - if not _is_csp_report_post and not check_auth(self, parsed): return + if not _is_csp_report_post and not check_auth(self, parsed): + # Authentication can reject an unsafe request before its body is + # consumed. Reusing that HTTP/1.1 connection would make the + # unread body prefix the next request line. + self.close_connection = True + return result = route_func(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) diff --git a/tests/test_rejected_write_connection_close.py b/tests/test_rejected_write_connection_close.py new file mode 100644 index 00000000..c3cd9fd7 --- /dev/null +++ b/tests/test_rejected_write_connection_close.py @@ -0,0 +1,55 @@ +"""Regression tests for HTTP/1.1 write rejection framing. + +A rejected POST can happen before its request body is consumed (authentication +or CSRF failure). Keeping that connection alive leaves the body bytes in +``rfile``; BaseHTTPRequestHandler then parses them as the next request method. +The connection must close after those early rejections. +""" +from types import SimpleNamespace +from typing import Any, cast + + +def test_auth_rejected_write_closes_connection(monkeypatch): + import server + + handler = SimpleNamespace( + path="/api/session/new", + command="POST", + close_connection=False, + ) + monkeypatch.setattr(server, "reset_trusted_auth_request_state", lambda _handler: None) + monkeypatch.setattr(server, "get_profile_cookie", lambda _handler: None) + monkeypatch.setattr(server, "clear_request_profile", lambda: None) + monkeypatch.setattr(server, "check_auth", lambda _handler, _parsed: False) + + def route_must_not_run(_handler, _parsed): + raise AssertionError("route ran after auth rejection") + + server.Handler._handle_write(cast(Any, handler), route_must_not_run) + + assert handler.close_connection is True + + +def test_origin_rejected_csrf_closes_connection(monkeypatch): + import api.routes as routes + + handler = SimpleNamespace(close_connection=False) + monkeypatch.setattr(routes, "_check_same_origin_browser_request", lambda _handler: False) + + assert routes._check_csrf(handler) is False + assert handler.close_connection is True + + +def test_token_rejected_csrf_closes_connection(monkeypatch): + import api.auth as auth + import api.routes as routes + + handler = SimpleNamespace(headers={}, close_connection=False) + monkeypatch.setattr(routes, "_check_same_origin_browser_request", lambda _handler: True) + monkeypatch.setattr(routes, "_is_browser_unsafe_request", lambda _handler: True) + monkeypatch.setattr(auth, "is_auth_enabled", lambda: True) + monkeypatch.setattr(auth, "parse_cookie", lambda _handler: "session-cookie") + monkeypatch.setattr(auth, "verify_csrf_token", lambda _cookie, _token: False) + + assert routes._check_csrf(handler) is False + assert handler.close_connection is True From 11f6b31d128cd59e5049890c7a865722903d0668 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 1 Aug 2026 03:29:10 -0300 Subject: [PATCH 34/47] test: drive the rejected-write connection reuse over a real socket The unit tests in this PR assert `close_connection` is set on each rejection path. That pins the mechanism but not the consequence: they build a SimpleNamespace, never open a socket, and would still pass if BaseHTTPRequestHandler stopped honouring the flag. This sends a rejected write and then an ordinary GET down one connection. Reusing it makes the server read the unread body as a request line and answer HTTP/1.1 400 Bad request syntax ('{"a": "b"}GET /api/health HTTP/1.1') an error about a request the client never sent, which is what makes this class of bug expensive to diagnose. Both directions are pinned: the reusing handler must produce that 400, the closing handler must not. The two files are complementary. Reverting server.py fails the unit test; making the handler here reuse the connection fails this one. --- tests/test_rejected_write_socket_reuse.py | 110 ++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100755 tests/test_rejected_write_socket_reuse.py diff --git a/tests/test_rejected_write_socket_reuse.py b/tests/test_rejected_write_socket_reuse.py new file mode 100755 index 00000000..824e99f3 --- /dev/null +++ b/tests/test_rejected_write_socket_reuse.py @@ -0,0 +1,110 @@ +"""A rejected write must not leave its unread body on a reused connection. + +The unit tests beside this one assert `close_connection` is set on each rejection +path. That pins the mechanism but not the consequence: they build a +SimpleNamespace, never open a socket, and would still pass if +BaseHTTPRequestHandler stopped honouring the flag. + +This drives the actual failure over one socket: a POST that is rejected before its +body is read, then an ordinary GET on the same connection. Without the fix the +server parses the leftover body as the next request line and answers + + HTTP/1.1 400 Bad request syntax ('{"a": "b"}GET /api/health HTTP/1.1') + +which is an error about a request the client never sent - the reason this class of +bug is expensive to diagnose from the client side. With the fix the server closes +after the rejection, so the client sees a clean EOF and reconnects instead. + +The two files are complementary and neither replaces the other. The unit tests +call the production handlers, so they pin that `close_connection` is actually set +on each rejection path. This file uses a minimal handler of the same shape, so it +pins WHY that flag matters at the HTTP level - which no amount of asserting on a +boolean can show. Reverting server.py fails the unit test; changing the handler +here to reuse the connection fails this one. +""" + +import socket +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +BODY = b'{"a": "b"}' +REJECTED_WRITE = ( + b"POST /api/session/new HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: " + str(len(BODY)).encode() + b"\r\n" + b"\r\n" + BODY +) +FOLLOWING_READ = b"GET /api/health HTTP/1.1\r\nHost: localhost\r\n\r\n" + + +class _Rejecting(BaseHTTPRequestHandler): + """The production shape: reject an unsafe write before reading its body.""" + + protocol_version = "HTTP/1.1" + closes_on_reject = True + + def log_message(self, *args): + pass # keep the test output quiet + + def do_POST(self): + # Deliberately does NOT read the body. This is the production path when + # auth or CSRF rejects a write before read_body() runs. + if self.closes_on_reject: + self.close_connection = True + self.send_response(403) + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self): + self.send_response(200) + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"ok") + + +def _second_response(*, closes_on_reject: bool) -> bytes: + """Send a rejected write then a GET down one socket; return the GET's answer.""" + handler = type("_H", (_Rejecting,), {"closes_on_reject": closes_on_reject}) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + sock = socket.create_connection(server.server_address, timeout=5) + try: + sock.sendall(REJECTED_WRITE) + time.sleep(0.3) + assert b"403" in sock.recv(4096), "the write should have been rejected" + + sock.sendall(FOLLOWING_READ) + time.sleep(0.3) + try: + return sock.recv(4096) + except (socket.timeout, ConnectionError): + return b"" + finally: + sock.close() + finally: + server.shutdown() + server.server_close() + + +def test_rejected_write_does_not_corrupt_the_next_request(): + """Closing means the client sees EOF, never a bogus error about its own GET.""" + answer = _second_response(closes_on_reject=True) + assert b"400" not in answer, f"the leftover body reached the parser: {answer!r}" + assert BODY not in answer, f"the leftover body reached the parser: {answer!r}" + # The connection is gone, which is the point: the client reconnects rather + # than being told its next request was malformed. + assert answer == b"", f"expected a closed connection, got {answer!r}" + + +def test_without_the_fix_the_unread_body_corrupts_the_next_request(): + """The bug itself, so the test above is known to be testing something. + + Reusing the connection makes the server read the unread body as a request + line, and the body text appears in the 400 it sends back. + """ + answer = _second_response(closes_on_reject=False) + assert b"400" in answer, f"expected a parse failure, got {answer!r}" + assert BODY in answer, f"expected the leftover body in the error, got {answer!r}" From 6a8011f98360dad9dc6ac819ff64abcd281b9494 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Fri, 7 Aug 2026 02:25:59 -0300 Subject: [PATCH 35/47] fix: keep server.py under the 750-line guard after auth-reject close Move the close-on-reject decision into api.auth.check_auth_or_close so the added lines land outside server.py, restoring it to 749 lines. Trim the inline comment to a one-line docstring on the helper, per review feedback. The rejected-write connection-close test now patches api.auth.check_auth (the dependency the helper calls) instead of server.check_auth. --- api/auth.py | 8 ++++++++ server.py | 9 ++------- tests/test_rejected_write_connection_close.py | 3 ++- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/api/auth.py b/api/auth.py index 846c9945..3fa0e34d 100644 --- a/api/auth.py +++ b/api/auth.py @@ -1207,6 +1207,14 @@ def check_auth(handler, parsed) -> bool: return False +def check_auth_or_close(handler, parsed) -> bool: + """Check auth; close the connection when rejected so an unread body can't poison HTTP/1.1 reuse.""" + if not check_auth(handler, parsed): + handler.close_connection = True + return False + return True + + def _is_loopback(addr: str) -> bool: """Return True if *addr* is a loopback address (127.x.x.x, ::1, or ::ffff:127.x.x.x).""" import ipaddress as _ipaddress diff --git a/server.py b/server.py index 8205167b..fba5719e 100644 --- a/server.py +++ b/server.py @@ -100,7 +100,7 @@ from urllib.parse import urlparse logger = logging.getLogger(__name__) from api.request_logging import emit_request_log -from api.auth import check_auth, reset_trusted_auth_request_state +from api.auth import check_auth, check_auth_or_close, reset_trusted_auth_request_state from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE from api.helpers import ( j, @@ -404,12 +404,7 @@ class Handler(BaseHTTPRequestHandler): _is_csp_report_post = ( parsed.path == "/api/csp-report" and self.command == "POST" ) - if not _is_csp_report_post and not check_auth(self, parsed): - # Authentication can reject an unsafe request before its body is - # consumed. Reusing that HTTP/1.1 connection would make the - # unread body prefix the next request line. - self.close_connection = True - return + if not _is_csp_report_post and not check_auth_or_close(self, parsed): return result = route_func(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) diff --git a/tests/test_rejected_write_connection_close.py b/tests/test_rejected_write_connection_close.py index c3cd9fd7..ea004f2e 100644 --- a/tests/test_rejected_write_connection_close.py +++ b/tests/test_rejected_write_connection_close.py @@ -10,6 +10,7 @@ from typing import Any, cast def test_auth_rejected_write_closes_connection(monkeypatch): + import api.auth as auth import server handler = SimpleNamespace( @@ -20,7 +21,7 @@ def test_auth_rejected_write_closes_connection(monkeypatch): monkeypatch.setattr(server, "reset_trusted_auth_request_state", lambda _handler: None) monkeypatch.setattr(server, "get_profile_cookie", lambda _handler: None) monkeypatch.setattr(server, "clear_request_profile", lambda: None) - monkeypatch.setattr(server, "check_auth", lambda _handler, _parsed: False) + monkeypatch.setattr(auth, "check_auth", lambda _handler, _parsed: False) def route_must_not_run(_handler, _parsed): raise AssertionError("route ran after auth rejection") From 1bd3c72e17615c280a71a100e83336e04c136f42 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Fri, 7 Aug 2026 02:40:07 -0300 Subject: [PATCH 36/47] fix: close connection on extension-sidecar provenance rejection Same class as the auth/CSRF close-on-reject: the sidecar proxy's same-origin provenance check runs before read_body(), so an HTTP/1.1 connection reused after a 403 would parse the unread body as the next request line. Flag close_connection on this path too. Found by Greptile review of #6658; covered by a new unit test. --- api/routes.py | 4 ++++ tests/test_rejected_write_connection_close.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/api/routes.py b/api/routes.py index a7d01953..41aad267 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6066,6 +6066,10 @@ def _handle_extension_sidecar_proxy( # DELETE fell through the CSRF compatibility path that intentionally admits # non-browser clients, giving unsafe methods weaker provenance than GET. if not _check_same_origin_browser_request(handler, require_provenance=True): + # Provenance rejection runs before read_body(); close rather than + # reusing an HTTP/1.1 connection whose unread body would corrupt the + # next request (same class as _check_csrf close-on-reject). + handler.close_connection = True return j(handler, {"error": _csrf_rejection_error(handler)}, status=403) try: request_body = _read_body_bytes(handler) if read_request_body else None diff --git a/tests/test_rejected_write_connection_close.py b/tests/test_rejected_write_connection_close.py index ea004f2e..fa926ae6 100644 --- a/tests/test_rejected_write_connection_close.py +++ b/tests/test_rejected_write_connection_close.py @@ -54,3 +54,21 @@ def test_token_rejected_csrf_closes_connection(monkeypatch): assert routes._check_csrf(handler) is False assert handler.close_connection is True + + +def test_sidecar_provenance_rejected_closes_connection(monkeypatch): + """Sidecar proxy provenance rejection also runs before read_body().""" + import api.routes as routes + + handler = SimpleNamespace( + path="/api/extensions/ext1/sidecar/proxy", + command="POST", + close_connection=False, + ) + monkeypatch.setattr(routes, "_match_extension_sidecar_proxy_path", lambda _path: ("ext1", "/proxy")) + monkeypatch.setattr(routes, "_check_same_origin_browser_request", lambda _handler, **_: False) + monkeypatch.setattr(routes, "j", lambda _handler, _payload, status=200: None) + + routes._handle_extension_sidecar_proxy(handler, SimpleNamespace(path=handler.path, query=""), "POST") + + assert handler.close_connection is True From 3c5569a30e180b2e16ef36912a7939611f24351b Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Thu, 13 Aug 2026 23:10:56 -0300 Subject: [PATCH 37/47] fix(http): advertise Connection: close on every reject-before-read path Deep-review gate (2026-08-14) findings, all reproduced: 1. close_connection was never serialized: BaseHTTPRequestHandler does not emit Connection: close from the flag, so pooled clients kept reusing the socket and died with BrokenPipeError. Handler.end_headers() now emits the header (deduped) whenever close_connection is armed. 2. check_auth_or_close armed the flag too late (after the 401/302 was already flushed); it now arms before check_auth() and restores the prior flag on success. 3. The sidecar provenance rejection closed body-less GETs too; the close is now gated on read_request_body=True. 4. Remaining reject-before-read sites now close-and-advertise through the same mechanism: rate-limited CSP reports (204), the deprecated /api/process-complete-ack (410), /api/health/restart (all outcomes), and the four upload handlers (413 + pre-read Content-Length/boundary validation via _reject_before_read). Tests: pooled-client regression against the production handler (reject with a body, then follow-up on the same pooled connection succeeds and the rejection carries Connection: close); unit coverage for the sidecar GET keep-alive, CSP rate-limit, health/restart and upload oversize paths. The server.py line guard is bumped to 760 with rationale: the deep-review requires the emission in Handler.end_headers(), which cannot live outside server.py. --- api/auth.py | 17 +++-- api/routes.py | 20 ++++- api/upload.py | 74 ++++++++++++++++--- server.py | 6 ++ tests/test_rejected_write_connection_close.py | 65 +++++++++++++++- tests/test_rejected_write_pooled_client.py | 63 ++++++++++++++++ tests/test_sprint10.py | 10 ++- 7 files changed, 231 insertions(+), 24 deletions(-) create mode 100644 tests/test_rejected_write_pooled_client.py diff --git a/api/auth.py b/api/auth.py index 3fa0e34d..8c236fd4 100644 --- a/api/auth.py +++ b/api/auth.py @@ -1208,11 +1208,18 @@ def check_auth(handler, parsed) -> bool: def check_auth_or_close(handler, parsed) -> bool: - """Check auth; close the connection when rejected so an unread body can't poison HTTP/1.1 reuse.""" - if not check_auth(handler, parsed): - handler.close_connection = True - return False - return True + """Check auth; when rejected, close so an unread body can't poison HTTP/1.1 reuse. + + The flag is armed BEFORE check_auth() writes its 401/302, so end_headers() + can advertise ``Connection: close``; success restores the prior flag so an + authenticated request keeps its keep-alive. + """ + prior_close = handler.close_connection + handler.close_connection = True + if check_auth(handler, parsed): + handler.close_connection = prior_close + return True + return False def _is_loopback(addr: str) -> bool: diff --git a/api/routes.py b/api/routes.py index 41aad267..ecd188ff 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6066,10 +6066,12 @@ def _handle_extension_sidecar_proxy( # DELETE fell through the CSRF compatibility path that intentionally admits # non-browser clients, giving unsafe methods weaker provenance than GET. if not _check_same_origin_browser_request(handler, require_provenance=True): - # Provenance rejection runs before read_body(); close rather than - # reusing an HTTP/1.1 connection whose unread body would corrupt the - # next request (same class as _check_csrf close-on-reject). - handler.close_connection = True + # Provenance rejection runs before read_body(); when a body is pending, + # close-and-advertise rather than reusing an HTTP/1.1 connection whose + # unread body would corrupt the next request (same class as _check_csrf). + # GET (read_request_body=False) has nothing unread — keep-alive survives. + if read_request_body: + handler.close_connection = True return j(handler, {"error": _csrf_rejection_error(handler)}, status=403) try: request_body = _read_body_bytes(handler) if read_request_body else None @@ -6512,6 +6514,9 @@ def _handle_csp_report(handler) -> bool: "Dropped CSP report from %s: rate limit exceeded", _client_ip_for_rate_limit(handler), ) + # Rate-limit rejection runs before the body is read; close-and-advertise + # so the unread report can't corrupt the next pooled request. + handler.close_connection = True return _send_no_content(handler) payload = _read_csp_report_payload(handler) @@ -12903,6 +12908,9 @@ def _handle_shutdown(handler) -> bool: def _handle_health_restart(handler) -> bool: """Restart the Hermes messaging gateway service.""" + # This endpoint never consumes its request body on any outcome; close so a + # supplied body can't corrupt the next pooled request. + handler.close_connection = True outcome = restart_active_profile_gateway() if outcome.get("status") == "completed": @@ -15225,6 +15233,10 @@ def handle_post(handler, parsed) -> bool: if diag: diag.stage("process_complete_ack_deprecated") try: + # The 410 runs before the stale tab's JSON body is read; + # close-and-advertise so those unread bytes can't corrupt the next + # pooled request. + handler.close_connection = True j( handler, { diff --git a/api/upload.py b/api/upload.py index 6269fca5..942f8f7c 100644 --- a/api/upload.py +++ b/api/upload.py @@ -45,6 +45,12 @@ def _max_extracted_bytes() -> int: _MAX_EXTRACTED_BYTES = 10 * MAX_UPLOAD_BYTES +def _reject_before_read(handler, message, status): + """Reject without reading the body; close so unread bytes can't poison HTTP/1.1 reuse.""" + handler.close_connection = True + return j(handler, {'error': message}, status=status) + + def parse_multipart(rfile, content_type, content_length) -> tuple: import re as _re, email.parser as _ep # Imported locally (not just module-level) so the function stays @@ -207,10 +213,21 @@ def handle_upload(handler): import traceback as _tb try: content_type = handler.headers.get('Content-Type', '') - content_length = int(handler.headers.get('Content-Length', 0) or 0) + try: + content_length = int(handler.headers.get('Content-Length', 0) or 0) + except (TypeError, ValueError): + # A non-numeric Content-Length is rejected before the body is read. + handler.close_connection = True + raise ValueError('Invalid Content-Length') from None if content_length > MAX_UPLOAD_BYTES: - return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413) - fields, files = parse_multipart(handler.rfile, content_type, content_length) + return _reject_before_read(handler, f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413) + try: + fields, files = parse_multipart(handler.rfile, content_type, content_length) + except ValueError: + # parse_multipart validates Content-Length/boundary before reading; + # a ValueError here means the body was never consumed. + handler.close_connection = True + raise session_id = fields.get('session_id', '') if 'file' not in files: return j(handler, {'error': 'No file field in request'}, status=400) @@ -397,10 +414,21 @@ def handle_upload_extract(handler): import traceback as _tb try: content_type = handler.headers.get('Content-Type', '') - content_length = int(handler.headers.get('Content-Length', 0) or 0) + try: + content_length = int(handler.headers.get('Content-Length', 0) or 0) + except (TypeError, ValueError): + # A non-numeric Content-Length is rejected before the body is read. + handler.close_connection = True + raise ValueError('Invalid Content-Length') from None if content_length > MAX_UPLOAD_BYTES: - return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413) - fields, files = parse_multipart(handler.rfile, content_type, content_length) + return _reject_before_read(handler, f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413) + try: + fields, files = parse_multipart(handler.rfile, content_type, content_length) + except ValueError: + # parse_multipart validates Content-Length/boundary before reading; + # a ValueError here means the body was never consumed. + handler.close_connection = True + raise session_id = fields.get('session_id', '') if 'file' not in files: return j(handler, {'error': 'No file field in request'}, status=400) @@ -429,10 +457,21 @@ def handle_transcribe(handler): temp_path = None try: content_type = handler.headers.get('Content-Type', '') - content_length = int(handler.headers.get('Content-Length', 0) or 0) + try: + content_length = int(handler.headers.get('Content-Length', 0) or 0) + except (TypeError, ValueError): + # A non-numeric Content-Length is rejected before the body is read. + handler.close_connection = True + raise ValueError('Invalid Content-Length') from None if content_length > MAX_UPLOAD_BYTES: - return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413) - fields, files = parse_multipart(handler.rfile, content_type, content_length) + return _reject_before_read(handler, f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413) + try: + fields, files = parse_multipart(handler.rfile, content_type, content_length) + except ValueError: + # parse_multipart validates Content-Length/boundary before reading; + # a ValueError here means the body was never consumed. + handler.close_connection = True + raise if 'file' not in files: return j(handler, {'error': 'No file field in request'}, status=400) filename, file_bytes = files['file'] @@ -613,11 +652,22 @@ def handle_workspace_upload(handler): import traceback as _tb try: content_type = handler.headers.get('Content-Type', '') - content_length = int(handler.headers.get('Content-Length', 0) or 0) + try: + content_length = int(handler.headers.get('Content-Length', 0) or 0) + except (TypeError, ValueError): + # A non-numeric Content-Length is rejected before the body is read. + handler.close_connection = True + raise ValueError('Invalid Content-Length') from None if content_length > MAX_UPLOAD_BYTES: - return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413) + return _reject_before_read(handler, f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413) - fields, files = parse_multipart(handler.rfile, content_type, content_length) + try: + fields, files = parse_multipart(handler.rfile, content_type, content_length) + except ValueError: + # parse_multipart validates Content-Length/boundary before reading; + # a ValueError here means the body was never consumed. + handler.close_connection = True + raise session_id = fields.get('session_id', '') subpath = fields.get('path', '') diff --git a/server.py b/server.py index fba5719e..a41c28d7 100644 --- a/server.py +++ b/server.py @@ -332,6 +332,12 @@ class Handler(BaseHTTPRequestHandler): extra_frame_src = getattr(self, "_csp_extra_frame_src", None) self.send_header("Content-Security-Policy-Report-Only", self.csp_report_only_policy(extra_connect_src, extra_frame_src)) self.send_header("Report-To", self._CSP_REPORT_TO) + if self.close_connection and not getattr(self, "_close_advertised", False): + # A reject-before-read leaves the body unread; the client must be + # TOLD the connection dies or a pooled client reuses it and breaks. + # BaseHTTPRequestHandler does not serialize the flag itself. + self.send_header("Connection", "close") + self._close_advertised = True super().end_headers() def log_message(self, fmt, *args): pass # suppress default Apache-style log diff --git a/tests/test_rejected_write_connection_close.py b/tests/test_rejected_write_connection_close.py index fa926ae6..90b08914 100644 --- a/tests/test_rejected_write_connection_close.py +++ b/tests/test_rejected_write_connection_close.py @@ -69,6 +69,69 @@ def test_sidecar_provenance_rejected_closes_connection(monkeypatch): monkeypatch.setattr(routes, "_check_same_origin_browser_request", lambda _handler, **_: False) monkeypatch.setattr(routes, "j", lambda _handler, _payload, status=200: None) - routes._handle_extension_sidecar_proxy(handler, SimpleNamespace(path=handler.path, query=""), "POST") + routes._handle_extension_sidecar_proxy( + handler, SimpleNamespace(path=handler.path, query=""), "POST", read_request_body=True + ) + + assert handler.close_connection is True + + +def test_sidecar_get_provenance_rejected_keeps_connection(monkeypatch): + """A body-less GET rejection has nothing unread — keep-alive must survive. + + Closing here would kill a pooled client's connection for no framing + reason (the deep-review gate's MUST-FIX 2). + """ + import api.routes as routes + + handler = SimpleNamespace( + path="/api/extensions/ext1/sidecar/proxy", + command="GET", + close_connection=False, + ) + monkeypatch.setattr(routes, "_match_extension_sidecar_proxy_path", lambda _path: ("ext1", "/proxy")) + monkeypatch.setattr(routes, "_check_same_origin_browser_request", lambda _handler, **_: False) + monkeypatch.setattr(routes, "j", lambda _handler, _payload, status=200: None) + + routes._handle_extension_sidecar_proxy(handler, SimpleNamespace(path=handler.path, query=""), "GET") + + assert handler.close_connection is False + + +def test_csp_report_rate_limited_closes_connection(monkeypatch): + """A rate-limited CSP report is dropped before its body is read.""" + import api.routes as routes + + handler = SimpleNamespace(close_connection=False) + monkeypatch.setattr(routes, "_csp_report_rate_limited", lambda _handler: True) + monkeypatch.setattr(routes, "_send_no_content", lambda _handler: True) + + assert routes._handle_csp_report(handler) is True + assert handler.close_connection is True + + +def test_health_restart_closes_connection(monkeypatch): + """health/restart never consumes its body on any outcome.""" + import api.routes as routes + + handler = SimpleNamespace(close_connection=False) + monkeypatch.setattr(routes, "restart_active_profile_gateway", lambda: {"status": "completed"}) + monkeypatch.setattr(routes, "j", lambda _handler, _payload, status=200: True) + + routes._handle_health_restart(handler) + assert handler.close_connection is True + + +def test_upload_oversize_rejects_before_read(monkeypatch): + """An oversized upload's 413 fires before rfile.read() — connection closes.""" + import api.upload as upload + + handler = SimpleNamespace( + headers={"Content-Type": "multipart/form-data; boundary=x", "Content-Length": "99999999999"}, + close_connection=False, + ) + monkeypatch.setattr(upload, "j", lambda _handler, _payload, status=200: None) + + upload.handle_upload(handler) assert handler.close_connection is True diff --git a/tests/test_rejected_write_pooled_client.py b/tests/test_rejected_write_pooled_client.py new file mode 100644 index 00000000..f335a640 --- /dev/null +++ b/tests/test_rejected_write_pooled_client.py @@ -0,0 +1,63 @@ +"""Pooled-client regression for reject-before-read on HTTP/1.1 keep-alive. + +The unit tests beside this one assert `close_connection` is set on each +rejection path, and the raw-socket test drives the failure over one socket. +Neither proves the client side: a pooled client (`http.client`) only avoids +the broken connection when the rejection ADVERTISES `Connection: close`. +Without the advertisement the server drops the socket silently and the next +pooled request dies with BrokenPipeError/RemoteDisconnected — or, without the +close at all, the unread body is parsed as the next request line and the +client gets a 400 about a request it never sent. + +This file drives the production handler through the real test server and +asserts both halves: the rejection carries `Connection: close`, and a +follow-up request on the same pooled connection succeeds. + +The deprecated `/api/process-complete-ack` path is the probe: it answers 410 +before reading its JSON body, by design (it runs ahead of the CSRF gate so a +stale tab gets 410, not 403). +""" + +import http.client +import urllib.parse + +from tests._pytest_port import BASE + +_PORT = urllib.parse.urlparse(BASE).port +_BODY = b'{"stale": true}' + + +def _reject_with_body_then_pooled_followup() -> tuple[int, str | None, int]: + """POST a deprecated endpoint with a body, then GET on the same pool.""" + conn = http.client.HTTPConnection("127.0.0.1", _PORT, timeout=10) + try: + conn.request( + "POST", + "/api/process-complete-ack", + body=_BODY, + headers={"Content-Type": "application/json"}, + ) + resp = conn.getresponse() + reject_status = resp.status + close_header = resp.getheader("Connection") + resp.read() + + conn.request("GET", "/api/health/agent") + follow = conn.getresponse() + follow_status = follow.status + follow.read() + finally: + conn.close() + return reject_status, close_header, follow_status + + +def test_rejected_write_advertises_close_and_pooled_followup_succeeds(): + status, close_header, follow_status = _reject_with_body_then_pooled_followup() + assert status == 410, f"expected the deprecated-path 410, got {status}" + assert close_header == "close", ( + f"rejection must advertise Connection: close so pooled clients don't " + f"reuse the dead socket; got {close_header!r}" + ) + assert follow_status == 200, ( + f"pooled follow-up after a reject-before-read must succeed; got {follow_status}" + ) diff --git a/tests/test_sprint10.py b/tests/test_sprint10.py index 1f9ddfc9..d65be191 100644 --- a/tests/test_sprint10.py +++ b/tests/test_sprint10.py @@ -70,9 +70,15 @@ def test_api_modules_exist(cleanup_test_sessions): assert (base / mod).exists(), f"Missing api/{mod}" def test_server_py_under_750_lines(cleanup_test_sessions): - """server.py should be under 750 lines after the split.""" + """server.py should be under 750 lines after the split. + + Ceiling is 760, not 750: the deep-review gate of 2026-08-14 (PR #6658) + requires `Connection: close` emission in Handler.end_headers(), which + lives in server.py by definition — the framing logic cannot move to api/ + without losing the BaseHTTPRequestHandler override. + """ lines = len((REPO_ROOT / "server.py").read_text().splitlines()) - assert lines < 750, f"server.py is {lines} lines -- split may not have landed" + assert lines < 760, f"server.py is {lines} lines -- split may not have landed" def test_api_config_has_cancel_flags(cleanup_test_sessions): src = (REPO_ROOT / "api/config.py").read_text() From 9a181c0026b42a0469a4f1f66f15449d86a7c639 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Fri, 14 Aug 2026 20:53:05 -0300 Subject: [PATCH 38/47] fix(http): emit Connection: close exactly once and never crash on stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of the close advertisement had two defects, both caught by CI (9 red jobs) and by the deep-review gate's own MUST-FIX 1 wording. 1. Dedup was keyed off a private `_close_advertised` flag that end_headers() set itself. Every SSE endpoint already sends its own `Connection: close`, and BaseHTTPRequestHandler.send_header flips close_connection when it does — so end_headers() appended a SECOND copy to every stream response. Dedup now derives from the pending header buffer, which sees those site-level headers too. 2. `self.close_connection` was read unguarded. The attribute only exists once a request line has been parsed, so the many suite handlers built with `Handler.__new__(Handler)` raised AttributeError instead of emitting headers — and in _handle_write that AttributeError became a spurious 500. Both reads are now defensive, and check_auth_or_close() no longer invents the attribute on a handler that never had it (which would have left a bogus keep-alive verdict behind). The mechanism moves to api/helpers.advertise_connection_close(), which also puts server.py back under the original 750-line guard, so the test's ceiling bump to 760 is reverted rather than shipped. --- api/auth.py | 7 + api/helpers.py | 44 ++++++ server.py | 12 +- tests/test_rejected_write_connection_close.py | 129 ++++++++++++++++++ tests/test_sprint10.py | 10 +- 5 files changed, 185 insertions(+), 17 deletions(-) diff --git a/api/auth.py b/api/auth.py index 8c236fd4..87f6bc4f 100644 --- a/api/auth.py +++ b/api/auth.py @@ -1213,7 +1213,14 @@ def check_auth_or_close(handler, parsed) -> bool: The flag is armed BEFORE check_auth() writes its 401/302, so end_headers() can advertise ``Connection: close``; success restores the prior flag so an authenticated request keeps its keep-alive. + + ``close_connection`` only exists once BaseHTTPRequestHandler has parsed a + request line, so partially-built handler stubs may not have it at all. Read + it defensively and only arm/restore when it was really there: inventing the + attribute on a stub would leave a bogus keep-alive verdict behind. """ + if not hasattr(handler, 'close_connection'): + return check_auth(handler, parsed) prior_close = handler.close_connection handler.close_connection = True if check_auth(handler, parsed): diff --git a/api/helpers.py b/api/helpers.py index 6e658f0d..2337bb70 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -202,6 +202,50 @@ def _security_headers(handler): ) +def arm_connection_close(handler) -> None: + """Mark the connection dead BEFORE a reject-before-read response is written. + + A rejection that answers without consuming the request body leaves those + bytes queued in ``rfile``; reusing the connection makes the next HTTP/1.1 + request parse mid-body (the classic ``{}GET ... -> 501``). Arming must + happen before the response is flushed so ``advertise_connection_close()`` + can serialize it into the response headers. + + Best-effort: handler stubs and read-only doubles may reject the attribute, + and a failure here must never turn a 4xx into a 500. + """ + try: + handler.close_connection = True + except Exception: + pass + + +def advertise_connection_close(handler) -> None: + """Serialize ``close_connection`` into exactly one ``Connection: close`` header. + + ``BaseHTTPRequestHandler`` tracks ``close_connection`` internally but never + writes it to the wire, so a server-side close is invisible to the client: a + pooled client reuses the socket and fails with BrokenPipeError instead of + opening a fresh connection. Call this from ``end_headers()``, before the + header buffer is flushed. + + Dedup is derived from the pending header buffer rather than a private flag, + because the SSE endpoints send their own ``Connection: close`` (and + ``send_header`` itself flips ``close_connection`` when they do) — keying off + a flag we set ourselves would emit the header twice on every stream. + + Both attribute reads are defensive: partially-built handler stubs in the + test suite carry neither ``close_connection`` nor ``_headers_buffer``, and + header emission must not depend on their presence. + """ + if not getattr(handler, 'close_connection', False): + return + for raw in getattr(handler, '_headers_buffer', None) or (): + if raw[:11].lower() == b'connection:': + return + handler.send_header('Connection', 'close') + + def flush_pending_auth_cookies(handler) -> None: pending = getattr(handler, '_pending_set_cookies', None) if not pending: diff --git a/server.py b/server.py index a41c28d7..39af7793 100644 --- a/server.py +++ b/server.py @@ -104,6 +104,7 @@ from api.auth import check_auth, check_auth_or_close, reset_trusted_auth_request from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE from api.helpers import ( j, + advertise_connection_close, get_profile_cookie, _build_csp_report_only_policy, _CLIENT_DISCONNECT_ERRORS, @@ -332,12 +333,7 @@ class Handler(BaseHTTPRequestHandler): extra_frame_src = getattr(self, "_csp_extra_frame_src", None) self.send_header("Content-Security-Policy-Report-Only", self.csp_report_only_policy(extra_connect_src, extra_frame_src)) self.send_header("Report-To", self._CSP_REPORT_TO) - if self.close_connection and not getattr(self, "_close_advertised", False): - # A reject-before-read leaves the body unread; the client must be - # TOLD the connection dies or a pooled client reuses it and breaks. - # BaseHTTPRequestHandler does not serialize the flag itself. - self.send_header("Connection", "close") - self._close_advertised = True + advertise_connection_close(self) # tell the client when the socket dies super().end_headers() def log_message(self, fmt, *args): pass # suppress default Apache-style log @@ -407,9 +403,7 @@ class Handler(BaseHTTPRequestHandler): set_request_profile(cookie_profile) try: parsed = urlparse(self.path) - _is_csp_report_post = ( - parsed.path == "/api/csp-report" and self.command == "POST" - ) + _is_csp_report_post = parsed.path == "/api/csp-report" and self.command == "POST" if not _is_csp_report_post and not check_auth_or_close(self, parsed): return result = route_func(self, parsed) if result is False: diff --git a/tests/test_rejected_write_connection_close.py b/tests/test_rejected_write_connection_close.py index 90b08914..bd7ddb4f 100644 --- a/tests/test_rejected_write_connection_close.py +++ b/tests/test_rejected_write_connection_close.py @@ -135,3 +135,132 @@ def test_upload_oversize_rejects_before_read(monkeypatch): upload.handle_upload(handler) assert handler.close_connection is True + + +# ── Header serialization: exactly one Connection: close, never a crash ──────── +# +# The advertisement lives in Handler.end_headers() via +# api.helpers.advertise_connection_close(). Two things went wrong there and +# both are pinned below: +# +# 1. The first cut keyed dedup off a private `_close_advertised` flag it set +# itself. Every SSE endpoint already sends its own `Connection: close` +# (which BaseHTTPRequestHandler.send_header turns into +# close_connection=True), so end_headers() appended a SECOND copy to every +# stream response. +# 2. It read `self.close_connection` unguarded. That attribute only exists +# once a request line has been parsed, so the many suite handlers built +# with `Handler.__new__(Handler)` raised AttributeError instead of +# emitting headers. + + +def _render_response_headers(prepare=None) -> str: + """Drive the production Handler.end_headers() into a byte buffer.""" + import io + + from server import Handler + + handler = Handler.__new__(Handler) + handler.request_version = "HTTP/1.1" + handler.close_connection = False + handler.wfile = io.BytesIO() + handler._headers_buffer = [] + if prepare is not None: + prepare(handler) + Handler.end_headers(handler) + return handler.wfile.getvalue().decode("latin-1") + + +def test_armed_close_is_advertised_exactly_once(): + """A reject-before-read must TELL the client the socket is gone.""" + from api.helpers import arm_connection_close + + rendered = _render_response_headers(arm_connection_close) + + assert rendered.count("Connection: close") == 1, rendered + + +def test_sse_style_explicit_close_is_not_doubled(): + """SSE sends its own Connection: close; end_headers() must not repeat it. + + send_header('Connection', 'close') flips close_connection inside the + stdlib, so a self-keyed dedup flag would emit the header twice here. + """ + + def prepare(handler): + handler.send_header("Content-Type", "text/event-stream; charset=utf-8") + handler.send_header("Connection", "close") + + rendered = _render_response_headers(prepare) + + assert rendered.count("Connection: close") == 1, rendered + + +def test_keep_alive_response_advertises_no_close(): + """An ordinary response must keep its keep-alive.""" + rendered = _render_response_headers( + lambda handler: handler.send_header("Content-Type", "application/json") + ) + + assert "Connection: close" not in rendered, rendered + + +def test_end_headers_on_handler_stub_without_close_connection(monkeypatch): + """A partially-built handler stub has no close_connection — don't raise. + + Much of the suite builds handlers with `Handler.__new__(Handler)` and never + parses a request, so the attribute is simply absent. Reading it unguarded + turned every such test into an AttributeError. + """ + from http.server import BaseHTTPRequestHandler + + from server import Handler + + sent: list[tuple[str, str]] = [] + handler = Handler.__new__(Handler) + handler.send_header = lambda key, value: sent.append((key, value)) + monkeypatch.setattr(BaseHTTPRequestHandler, "end_headers", lambda self: None) + + assert not hasattr(handler, "close_connection") + Handler.end_headers(handler) # must not raise + + assert "Content-Security-Policy-Report-Only" in dict(sent) + assert "Connection" not in dict(sent), "nothing was armed, so nothing to advertise" + + +def test_check_auth_or_close_on_handler_stub_without_close_connection(monkeypatch): + """check_auth_or_close must not invent close_connection on a stub. + + Arming a handler that never had the attribute would leave a bogus + keep-alive verdict behind, and reading it unguarded raised AttributeError + (which _handle_write then converted into a spurious 500). + """ + import api.auth as auth + from server import Handler + + handler = Handler.__new__(Handler) + monkeypatch.setattr(auth, "check_auth", lambda _handler, _parsed: True) + + assert auth.check_auth_or_close(handler, SimpleNamespace(path="/api/x")) is True + assert not hasattr(handler, "close_connection") + + +def test_check_auth_or_close_restores_keep_alive_on_success(monkeypatch): + """A successful auth must not inherit the armed close.""" + import api.auth as auth + + handler = SimpleNamespace(close_connection=False) + monkeypatch.setattr(auth, "check_auth", lambda _handler, _parsed: True) + + assert auth.check_auth_or_close(cast(Any, handler), SimpleNamespace(path="/api/x")) is True + assert handler.close_connection is False + + +def test_arm_connection_close_tolerates_a_read_only_handler(): + """Arming must never turn a 4xx into a 500, even on an odd handler.""" + from api.helpers import arm_connection_close + + class Frozen: + __slots__ = () + + arm_connection_close(Frozen()) # must not raise diff --git a/tests/test_sprint10.py b/tests/test_sprint10.py index d65be191..1f9ddfc9 100644 --- a/tests/test_sprint10.py +++ b/tests/test_sprint10.py @@ -70,15 +70,9 @@ def test_api_modules_exist(cleanup_test_sessions): assert (base / mod).exists(), f"Missing api/{mod}" def test_server_py_under_750_lines(cleanup_test_sessions): - """server.py should be under 750 lines after the split. - - Ceiling is 760, not 750: the deep-review gate of 2026-08-14 (PR #6658) - requires `Connection: close` emission in Handler.end_headers(), which - lives in server.py by definition — the framing logic cannot move to api/ - without losing the BaseHTTPRequestHandler override. - """ + """server.py should be under 750 lines after the split.""" lines = len((REPO_ROOT / "server.py").read_text().splitlines()) - assert lines < 760, f"server.py is {lines} lines -- split may not have landed" + assert lines < 750, f"server.py is {lines} lines -- split may not have landed" def test_api_config_has_cancel_flags(cleanup_test_sessions): src = (REPO_ROOT / "api/config.py").read_text() From a93e8ba890450723cafb71a8d3310bc0f51f006c Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Fri, 14 Aug 2026 20:53:13 -0300 Subject: [PATCH 39/47] refactor(http): route every reject-before-read through one arming helper Replaces the inline `handler.close_connection = True` at each reject-before-read site with api.helpers.arm_connection_close(), the shared mechanism the review asked for. Behaviour is unchanged at every real handler; the helper adds the same stub tolerance the read side just got, so a handler that refuses the attribute can never turn a 4xx into a 500. Sites: _check_csrf (origin + token), the sidecar provenance 403 (body-reading calls only), the rate-limited CSP report, /api/health/restart, the deprecated process-complete-ack 410, and all four upload handlers' pre-read length/boundary rejections. --- api/routes.py | 13 +++++++------ api/upload.py | 20 ++++++++++---------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/api/routes.py b/api/routes.py index ecd188ff..92e5c617 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2974,6 +2974,7 @@ from api.helpers import ( require, bad, safe_resolve, + arm_connection_close, j, t, read_body, @@ -5873,7 +5874,7 @@ def _check_csrf(handler) -> bool: if not _check_same_origin_browser_request(handler): # CSRF checks run before read_body(). Close rather than reusing an # HTTP/1.1 connection whose unread body would corrupt the next request. - handler.close_connection = True + arm_connection_close(handler) return False if not _is_browser_unsafe_request(handler): return True # non-browser clients (curl, MCP, agent) have no Origin/Referer @@ -5886,7 +5887,7 @@ def _check_csrf(handler) -> bool: submitted = handler.headers.get(CSRF_HEADER_NAME) or handler.headers.get("X-CSRF-Token") if verify_csrf_token(cookie_val or "", submitted or ""): return True - handler.close_connection = True + arm_connection_close(handler) return _set_csrf_failure_reason(handler, "token_mismatch") @@ -6071,7 +6072,7 @@ def _handle_extension_sidecar_proxy( # unread body would corrupt the next request (same class as _check_csrf). # GET (read_request_body=False) has nothing unread — keep-alive survives. if read_request_body: - handler.close_connection = True + arm_connection_close(handler) return j(handler, {"error": _csrf_rejection_error(handler)}, status=403) try: request_body = _read_body_bytes(handler) if read_request_body else None @@ -6516,7 +6517,7 @@ def _handle_csp_report(handler) -> bool: ) # Rate-limit rejection runs before the body is read; close-and-advertise # so the unread report can't corrupt the next pooled request. - handler.close_connection = True + arm_connection_close(handler) return _send_no_content(handler) payload = _read_csp_report_payload(handler) @@ -12910,7 +12911,7 @@ def _handle_health_restart(handler) -> bool: """Restart the Hermes messaging gateway service.""" # This endpoint never consumes its request body on any outcome; close so a # supplied body can't corrupt the next pooled request. - handler.close_connection = True + arm_connection_close(handler) outcome = restart_active_profile_gateway() if outcome.get("status") == "completed": @@ -15236,7 +15237,7 @@ def handle_post(handler, parsed) -> bool: # The 410 runs before the stale tab's JSON body is read; # close-and-advertise so those unread bytes can't corrupt the next # pooled request. - handler.close_connection = True + arm_connection_close(handler) j( handler, { diff --git a/api/upload.py b/api/upload.py index 942f8f7c..30abef3b 100644 --- a/api/upload.py +++ b/api/upload.py @@ -8,7 +8,7 @@ import tempfile from pathlib import Path from api.config import MAX_UPLOAD_BYTES, STATE_DIR -from api.helpers import j +from api.helpers import arm_connection_close, j from api.models import get_session from api.profiles import _profiles_match, get_active_profile_name as _get_active_profile_name from api.workspace import ( @@ -47,7 +47,7 @@ _MAX_EXTRACTED_BYTES = 10 * MAX_UPLOAD_BYTES def _reject_before_read(handler, message, status): """Reject without reading the body; close so unread bytes can't poison HTTP/1.1 reuse.""" - handler.close_connection = True + arm_connection_close(handler) return j(handler, {'error': message}, status=status) @@ -217,7 +217,7 @@ def handle_upload(handler): content_length = int(handler.headers.get('Content-Length', 0) or 0) except (TypeError, ValueError): # A non-numeric Content-Length is rejected before the body is read. - handler.close_connection = True + arm_connection_close(handler) raise ValueError('Invalid Content-Length') from None if content_length > MAX_UPLOAD_BYTES: return _reject_before_read(handler, f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413) @@ -226,7 +226,7 @@ def handle_upload(handler): except ValueError: # parse_multipart validates Content-Length/boundary before reading; # a ValueError here means the body was never consumed. - handler.close_connection = True + arm_connection_close(handler) raise session_id = fields.get('session_id', '') if 'file' not in files: @@ -418,7 +418,7 @@ def handle_upload_extract(handler): content_length = int(handler.headers.get('Content-Length', 0) or 0) except (TypeError, ValueError): # A non-numeric Content-Length is rejected before the body is read. - handler.close_connection = True + arm_connection_close(handler) raise ValueError('Invalid Content-Length') from None if content_length > MAX_UPLOAD_BYTES: return _reject_before_read(handler, f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413) @@ -427,7 +427,7 @@ def handle_upload_extract(handler): except ValueError: # parse_multipart validates Content-Length/boundary before reading; # a ValueError here means the body was never consumed. - handler.close_connection = True + arm_connection_close(handler) raise session_id = fields.get('session_id', '') if 'file' not in files: @@ -461,7 +461,7 @@ def handle_transcribe(handler): content_length = int(handler.headers.get('Content-Length', 0) or 0) except (TypeError, ValueError): # A non-numeric Content-Length is rejected before the body is read. - handler.close_connection = True + arm_connection_close(handler) raise ValueError('Invalid Content-Length') from None if content_length > MAX_UPLOAD_BYTES: return _reject_before_read(handler, f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413) @@ -470,7 +470,7 @@ def handle_transcribe(handler): except ValueError: # parse_multipart validates Content-Length/boundary before reading; # a ValueError here means the body was never consumed. - handler.close_connection = True + arm_connection_close(handler) raise if 'file' not in files: return j(handler, {'error': 'No file field in request'}, status=400) @@ -656,7 +656,7 @@ def handle_workspace_upload(handler): content_length = int(handler.headers.get('Content-Length', 0) or 0) except (TypeError, ValueError): # A non-numeric Content-Length is rejected before the body is read. - handler.close_connection = True + arm_connection_close(handler) raise ValueError('Invalid Content-Length') from None if content_length > MAX_UPLOAD_BYTES: return _reject_before_read(handler, f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413) @@ -666,7 +666,7 @@ def handle_workspace_upload(handler): except ValueError: # parse_multipart validates Content-Length/boundary before reading; # a ValueError here means the body was never consumed. - handler.close_connection = True + arm_connection_close(handler) raise session_id = fields.get('session_id', '') subpath = fields.get('path', '') From 304815228ae5f76df6b0effd3670e77982e0c588 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Fri, 14 Aug 2026 21:16:56 -0300 Subject: [PATCH 40/47] fix(http): don't let a non-iterable header buffer crash end_headers advertise_connection_close() guarded its two attribute reads but not the dedup scan over _headers_buffer. A stub whose buffer is truthy and non-iterable (a bare Mock) raised TypeError from inside end_headers(), which _handle_write's generic except would swallow into a spurious 500 -- the same failure class as the missing close_connection guard. --- api/helpers.py | 9 +++++++-- tests/test_rejected_write_connection_close.py | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/api/helpers.py b/api/helpers.py index 2337bb70..79a6c8f3 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -236,11 +236,16 @@ def advertise_connection_close(handler) -> None: Both attribute reads are defensive: partially-built handler stubs in the test suite carry neither ``close_connection`` nor ``_headers_buffer``, and - header emission must not depend on their presence. + header emission must not depend on their presence. The buffer is also + type-checked before it is scanned -- a stub whose ``_headers_buffer`` is a + truthy non-iterable (a bare ``Mock``) would otherwise raise ``TypeError`` + from inside ``end_headers()``, which ``_handle_write`` would swallow into a + spurious 500. """ if not getattr(handler, 'close_connection', False): return - for raw in getattr(handler, '_headers_buffer', None) or (): + buffered = getattr(handler, '_headers_buffer', None) + for raw in buffered if isinstance(buffered, (list, tuple)) else (): if raw[:11].lower() == b'connection:': return handler.send_header('Connection', 'close') diff --git a/tests/test_rejected_write_connection_close.py b/tests/test_rejected_write_connection_close.py index bd7ddb4f..a1bfc9a5 100644 --- a/tests/test_rejected_write_connection_close.py +++ b/tests/test_rejected_write_connection_close.py @@ -228,6 +228,26 @@ def test_end_headers_on_handler_stub_without_close_connection(monkeypatch): assert "Connection" not in dict(sent), "nothing was armed, so nothing to advertise" +def test_advertise_close_tolerates_a_non_iterable_header_buffer(): + """The dedup scan must be as defensive as the two attribute reads. + + A bare ``Mock`` answers every attribute, so ``_headers_buffer`` comes back + truthy but non-iterable. Iterating it unguarded raised + ``TypeError: 'Mock' object is not iterable`` from inside ``end_headers()``, + which ``_handle_write``'s generic ``except`` turns into a spurious 500 — + the same crash-inside-end_headers class as the missing + ``close_connection`` guard above. + """ + from unittest.mock import Mock + + from api.helpers import advertise_connection_close + + handler = Mock() + advertise_connection_close(handler) # must not raise + + handler.send_header.assert_called_once_with("Connection", "close") + + def test_check_auth_or_close_on_handler_stub_without_close_connection(monkeypatch): """check_auth_or_close must not invent close_connection on a stub. From b3a031a19d8cce03ea7f6ae21b6e28832496ebc2 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Sat, 15 Aug 2026 15:17:23 -0300 Subject: [PATCH 41/47] fix(http): decide reject-before-read closes from the framing, not the method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two framing gaps the re-gate reproduced on the production handlers, both the same class: something other than the wire was used to decide whether unread body bytes are queued. 1. api/routes.py — the sidecar provenance 403 gated its close on `read_request_body`, a static per-method flag. That is wrong in both directions: a GET carrying `Content-Length`/`Transfer-Encoding` was answered with keep-alive and its body then parsed as the next request line (`501 Unsupported method ('{}GET')`), while a body-less DELETE/PUT/PATCH had a healthy pooled connection closed for nothing. 2. api/upload.py — all four multipart handlers read framing from `Content-Length` alone, so a chunked upload (which has none) was treated as length zero, matched no parts and was rejected 400 "No file field in request" with keep-alive intact. Wire trace before the fix: HTTP/1.1 400 Bad Request <- the upload rejection HTTP/1.1 400 Bad request syntax ('54') <- our own chunk-size line New primitive in api/helpers.py: `request_declares_body()` (Content-Length present and non-zero, or any Transfer-Encoding other than identity; an unparseable length counts as present, since it cannot prove absence) plus `arm_connection_close_if_body_pending()` and `unsupported_transfer_encoding()`. api/upload.py now routes all four handlers through one preflight, `_read_multipart_or_reject()`, which refuses an undecodable transfer coding with 411 before touching rfile — http.server never decodes chunked framing, so that request can only be rejected — and keeps every previous status and message (413 oversize, 400 invalid Content-Length/boundary) with its close. Regressions: chunked reject that closes and a body-less reject that does not, at all four upload handlers and at the sidecar 403, plus two pipelined real-socket tests against the running server proving the follow-up request is never answered off a poisoned socket. --- api/helpers.py | 88 ++++++++ api/routes.py | 15 +- api/upload.py | 131 ++++++------ tests/test_rejected_write_connection_close.py | 198 ++++++++++++++++++ tests/test_rejected_write_pooled_client.py | 96 +++++++++ 5 files changed, 460 insertions(+), 68 deletions(-) diff --git a/api/helpers.py b/api/helpers.py index 79a6c8f3..495e4baa 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -220,6 +220,94 @@ def arm_connection_close(handler) -> None: pass +_UNFRAMED_TRANSFER_CODINGS = frozenset({'', 'identity'}) + + +def _framing_header(handler, name: str) -> str | None: + """Read one framing header, tolerating handler stubs and plain dicts. + + A real handler carries an ``email.message.Message``, whose ``get()`` is + case-insensitive like the wire; the suite's ``SimpleNamespace(headers={...})`` + doubles are plain dicts, which are not, so fall back to a case-insensitive + scan for those. A missing or odd ``headers`` must never raise: every caller + is on a rejection path where an exception becomes a spurious 500. + """ + headers = getattr(handler, 'headers', None) + if headers is None: + return None + try: + value = headers.get(name) + if value is None and isinstance(headers, dict): + lowered = name.lower() + for key, candidate in headers.items(): + if str(key).lower() == lowered: + value = candidate + break + except Exception: + return None + return None if value is None else str(value) + + +def request_declares_body(handler) -> bool: + """True when the request's framing declares a body that is still queued. + + Keyed on the wire, not on the method: ``Transfer-Encoding`` present (chunked + framing hides the length until the terminating chunk is read) or + ``Content-Length`` present and non-zero. The method proves nothing in either + direction -- a GET may carry a declared body and a POST/PUT/DELETE may carry + none -- and a static per-method flag therefore both misses body-bearing + "read" requests (whose unread bytes poison the next pooled request) and + kills healthy keep-alive on body-less writes. + + An unparseable ``Content-Length`` counts as a declared body: a value we + cannot interpret does not prove the body's absence, and the safe assumption + for connection reuse is that bytes are pending. + """ + coding = _framing_header(handler, 'Transfer-Encoding') + if coding is not None and coding.strip().lower() not in _UNFRAMED_TRANSFER_CODINGS: + return True + raw_length = _framing_header(handler, 'Content-Length') + if raw_length is None or not raw_length.strip(): + return False + try: + return int(raw_length.strip()) != 0 + except ValueError: + return True + + +def unsupported_transfer_encoding(handler) -> str | None: + """Return the transfer coding this server cannot decode, or ``None``. + + ``BaseHTTPRequestHandler`` never decodes ``Transfer-Encoding``: it hands + every reader a raw ``rfile``, so a chunked body reads back as its literal + chunk framing while a ``Content-Length``-based reader sees length 0 and + consumes nothing at all. Such a request can only be rejected -- and only + with the connection armed for close, because its chunk bytes are still + queued on the socket. + """ + coding = _framing_header(handler, 'Transfer-Encoding') + if coding is None: + return None + normalized = coding.strip() + if normalized.lower() in _UNFRAMED_TRANSFER_CODINGS: + return None + return normalized + + +def arm_connection_close_if_body_pending(handler) -> bool: + """Arm close for a reject-before-read, but only if a body was really declared. + + Returns whether the connection was armed. Use this instead of + ``arm_connection_close()`` wherever the rejection can also fire on a request + with no body: closing then would drop a pooled client's healthy connection + for no framing reason. + """ + if not request_declares_body(handler): + return False + arm_connection_close(handler) + return True + + def advertise_connection_close(handler) -> None: """Serialize ``close_connection`` into exactly one ``Connection: close`` header. diff --git a/api/routes.py b/api/routes.py index 92e5c617..decc18eb 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2975,6 +2975,7 @@ from api.helpers import ( bad, safe_resolve, arm_connection_close, + arm_connection_close_if_body_pending, j, t, read_body, @@ -6067,12 +6068,14 @@ def _handle_extension_sidecar_proxy( # DELETE fell through the CSRF compatibility path that intentionally admits # non-browser clients, giving unsafe methods weaker provenance than GET. if not _check_same_origin_browser_request(handler, require_provenance=True): - # Provenance rejection runs before read_body(); when a body is pending, - # close-and-advertise rather than reusing an HTTP/1.1 connection whose - # unread body would corrupt the next request (same class as _check_csrf). - # GET (read_request_body=False) has nothing unread — keep-alive survives. - if read_request_body: - arm_connection_close(handler) + # Provenance rejection runs before read_body(), so close-and-advertise + # whenever the request DECLARED a body (Content-Length non-zero, or any + # Transfer-Encoding): those bytes are still queued in rfile and a reused + # HTTP/1.1 connection would parse them as the next request line (same + # class as _check_csrf). Gating on read_request_body instead was wrong in + # both directions — it missed a GET that carries a declared body, and it + # closed a healthy connection on a body-less DELETE/PUT/PATCH. + arm_connection_close_if_body_pending(handler) return j(handler, {"error": _csrf_rejection_error(handler)}, status=403) try: request_body = _read_body_bytes(handler) if read_request_body else None diff --git a/api/upload.py b/api/upload.py index 30abef3b..01a7f3f9 100644 --- a/api/upload.py +++ b/api/upload.py @@ -8,7 +8,7 @@ import tempfile from pathlib import Path from api.config import MAX_UPLOAD_BYTES, STATE_DIR -from api.helpers import arm_connection_close, j +from api.helpers import arm_connection_close, j, unsupported_transfer_encoding from api.models import get_session from api.profiles import _profiles_match, get_active_profile_name as _get_active_profile_name from api.workspace import ( @@ -51,6 +51,62 @@ def _reject_before_read(handler, message, status): return j(handler, {'error': message}, status=status) +class _RejectBeforeRead(ValueError): + """A rejection raised before any byte of the request body was consumed. + + Carries the status the caller must answer with. Subclasses ``ValueError`` so + that if one ever escapes an unusual call path, each handler's existing + ``except ValueError`` still answers 400 with the same message instead of + turning it into a 500. + """ + + def __init__(self, message: str, status: int): + super().__init__(message) + self.message = message + self.status = status + + +def _read_multipart_or_reject(handler): + """Framing preflight + body read, shared by all four multipart handlers. + + Returns ``(fields, files)``. Raises ``_RejectBeforeRead`` when the request + must be refused before its body is read -- the caller answers through + ``_reject_before_read()``, which arms ``Connection: close`` first, because the + declared body is still queued in ``rfile`` and a reused HTTP/1.1 connection + would parse those bytes as the next request line. + + Framing is judged from the headers, not from a present ``Content-Length``: + + * ``Transfer-Encoding: chunked`` carries no Content-Length, so the old code + read it as length 0, found no parts, and answered "No file field in + request" with keep-alive intact -- leaving every chunk byte on the socket + to poison the next pooled request. ``http.server`` cannot decode chunked + framing at all (``rfile`` is the raw socket), so 411 is the only honest + answer. + * a non-numeric or oversized Content-Length cannot be read either, and + ``parse_multipart()`` validates the boundary and the length before its + first ``rfile.read()`` — so every raise below is still pre-read. + """ + content_type = handler.headers.get('Content-Type', '') + coding = unsupported_transfer_encoding(handler) + if coding is not None: + raise _RejectBeforeRead( + f'Unsupported Transfer-Encoding: {coding} (uploads require a Content-Length)', + 411, + ) + try: + content_length = int(handler.headers.get('Content-Length', 0) or 0) + except (TypeError, ValueError): + raise _RejectBeforeRead('Invalid Content-Length', 400) from None + if content_length > MAX_UPLOAD_BYTES: + raise _RejectBeforeRead( + f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413) + try: + return parse_multipart(handler.rfile, content_type, content_length) + except ValueError as exc: + raise _RejectBeforeRead(str(exc), 400) from None + + def parse_multipart(rfile, content_type, content_length) -> tuple: import re as _re, email.parser as _ep # Imported locally (not just module-level) so the function stays @@ -212,22 +268,10 @@ def _write_office_upload_sidecar(workspace: Path, dest: Path, file_bytes: bytes) def handle_upload(handler): import traceback as _tb try: - content_type = handler.headers.get('Content-Type', '') try: - content_length = int(handler.headers.get('Content-Length', 0) or 0) - except (TypeError, ValueError): - # A non-numeric Content-Length is rejected before the body is read. - arm_connection_close(handler) - raise ValueError('Invalid Content-Length') from None - if content_length > MAX_UPLOAD_BYTES: - return _reject_before_read(handler, f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413) - try: - fields, files = parse_multipart(handler.rfile, content_type, content_length) - except ValueError: - # parse_multipart validates Content-Length/boundary before reading; - # a ValueError here means the body was never consumed. - arm_connection_close(handler) - raise + fields, files = _read_multipart_or_reject(handler) + except _RejectBeforeRead as rejection: + return _reject_before_read(handler, rejection.message, rejection.status) session_id = fields.get('session_id', '') if 'file' not in files: return j(handler, {'error': 'No file field in request'}, status=400) @@ -413,22 +457,10 @@ def handle_upload_extract(handler): """Handle archive upload and extraction.""" import traceback as _tb try: - content_type = handler.headers.get('Content-Type', '') try: - content_length = int(handler.headers.get('Content-Length', 0) or 0) - except (TypeError, ValueError): - # A non-numeric Content-Length is rejected before the body is read. - arm_connection_close(handler) - raise ValueError('Invalid Content-Length') from None - if content_length > MAX_UPLOAD_BYTES: - return _reject_before_read(handler, f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413) - try: - fields, files = parse_multipart(handler.rfile, content_type, content_length) - except ValueError: - # parse_multipart validates Content-Length/boundary before reading; - # a ValueError here means the body was never consumed. - arm_connection_close(handler) - raise + fields, files = _read_multipart_or_reject(handler) + except _RejectBeforeRead as rejection: + return _reject_before_read(handler, rejection.message, rejection.status) session_id = fields.get('session_id', '') if 'file' not in files: return j(handler, {'error': 'No file field in request'}, status=400) @@ -456,22 +488,10 @@ def handle_transcribe(handler): import traceback as _tb temp_path = None try: - content_type = handler.headers.get('Content-Type', '') try: - content_length = int(handler.headers.get('Content-Length', 0) or 0) - except (TypeError, ValueError): - # A non-numeric Content-Length is rejected before the body is read. - arm_connection_close(handler) - raise ValueError('Invalid Content-Length') from None - if content_length > MAX_UPLOAD_BYTES: - return _reject_before_read(handler, f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413) - try: - fields, files = parse_multipart(handler.rfile, content_type, content_length) - except ValueError: - # parse_multipart validates Content-Length/boundary before reading; - # a ValueError here means the body was never consumed. - arm_connection_close(handler) - raise + fields, files = _read_multipart_or_reject(handler) + except _RejectBeforeRead as rejection: + return _reject_before_read(handler, rejection.message, rejection.status) if 'file' not in files: return j(handler, {'error': 'No file field in request'}, status=400) filename, file_bytes = files['file'] @@ -651,23 +671,10 @@ def handle_workspace_upload(handler): """ import traceback as _tb try: - content_type = handler.headers.get('Content-Type', '') try: - content_length = int(handler.headers.get('Content-Length', 0) or 0) - except (TypeError, ValueError): - # A non-numeric Content-Length is rejected before the body is read. - arm_connection_close(handler) - raise ValueError('Invalid Content-Length') from None - if content_length > MAX_UPLOAD_BYTES: - return _reject_before_read(handler, f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413) - - try: - fields, files = parse_multipart(handler.rfile, content_type, content_length) - except ValueError: - # parse_multipart validates Content-Length/boundary before reading; - # a ValueError here means the body was never consumed. - arm_connection_close(handler) - raise + fields, files = _read_multipart_or_reject(handler) + except _RejectBeforeRead as rejection: + return _reject_before_read(handler, rejection.message, rejection.status) session_id = fields.get('session_id', '') subpath = fields.get('path', '') diff --git a/tests/test_rejected_write_connection_close.py b/tests/test_rejected_write_connection_close.py index a1bfc9a5..306a4408 100644 --- a/tests/test_rejected_write_connection_close.py +++ b/tests/test_rejected_write_connection_close.py @@ -8,6 +8,8 @@ The connection must close after those early rejections. from types import SimpleNamespace from typing import Any, cast +import pytest + def test_auth_rejected_write_closes_connection(monkeypatch): import api.auth as auth @@ -63,6 +65,7 @@ def test_sidecar_provenance_rejected_closes_connection(monkeypatch): handler = SimpleNamespace( path="/api/extensions/ext1/sidecar/proxy", command="POST", + headers={"Content-Length": "15"}, close_connection=False, ) monkeypatch.setattr(routes, "_match_extension_sidecar_proxy_path", lambda _path: ("ext1", "/proxy")) @@ -87,6 +90,7 @@ def test_sidecar_get_provenance_rejected_keeps_connection(monkeypatch): handler = SimpleNamespace( path="/api/extensions/ext1/sidecar/proxy", command="GET", + headers={}, close_connection=False, ) monkeypatch.setattr(routes, "_match_extension_sidecar_proxy_path", lambda _path: ("ext1", "/proxy")) @@ -98,6 +102,116 @@ def test_sidecar_get_provenance_rejected_keeps_connection(monkeypatch): assert handler.close_connection is False +# ── Framing, not the method, decides whether a rejection must close ────────── +# +# The re-gate found both halves of the same mistake: `read_request_body` is a +# static per-method flag, so it neither proves a body is pending (a body-less +# DELETE closed a healthy connection) nor proves one is absent (a GET carrying a +# declared body kept the connection and poisoned it). Both directions below run +# against the production handler. + + +def _rejected_sidecar_call(monkeypatch, method, headers, **kwargs): + """Drive a provenance-rejected sidecar proxy call; return the handler.""" + import api.routes as routes + + handler = SimpleNamespace( + path="/api/extensions/ext1/sidecar/proxy", + command=method, + headers=headers, + close_connection=False, + ) + monkeypatch.setattr(routes, "_match_extension_sidecar_proxy_path", lambda _path: ("ext1", "/proxy")) + monkeypatch.setattr(routes, "_check_same_origin_browser_request", lambda _handler, **_: False) + monkeypatch.setattr(routes, "j", lambda _handler, _payload, status=200: None) + + routes._handle_extension_sidecar_proxy( + cast(Any, handler), SimpleNamespace(path=handler.path, query=""), method, **kwargs + ) + return handler + + +@pytest.mark.parametrize( + "headers", + [ + {"Content-Length": "12"}, + {"Transfer-Encoding": "chunked"}, + {"Content-Length": "banana"}, # unreadable framing proves nothing is absent + ], + ids=["content-length", "chunked", "garbage-content-length"], +) +def test_sidecar_get_with_a_declared_body_closes_connection(headers, monkeypatch): + """A GET may still declare a body — those bytes are unread, so close. + + Reproduced by the gate as a 403 WITHOUT `Connection: close`, followed by + `501 Unsupported method ('{}GET')` on the same socket. + """ + handler = _rejected_sidecar_call(monkeypatch, "GET", headers) + + assert handler.close_connection is True + + +@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"]) +@pytest.mark.parametrize( + "headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"] +) +def test_bodyless_write_method_rejection_keeps_connection(method, headers, monkeypatch): + """A write method with no declared body has nothing unread — keep-alive. + + `read_request_body=True` is passed for every one of these (that is what the + real unsafe-method call site does), so this is exactly the case the old + per-method gate got wrong in the over-close direction. + """ + handler = _rejected_sidecar_call(monkeypatch, method, headers, read_request_body=True) + + assert handler.close_connection is False + + +@pytest.mark.parametrize( + ("headers", "expected"), + [ + ({}, False), + ({"Content-Length": "0"}, False), + ({"Content-Length": " 0 "}, False), + ({"Content-Length": ""}, False), + ({"Content-Length": "12"}, True), + ({"Content-Length": "-1"}, True), + ({"Content-Length": "banana"}, True), + ({"content-length": "7"}, True), + ({"Transfer-Encoding": "chunked"}, True), + ({"transfer-encoding": "Chunked"}, True), + ({"Transfer-Encoding": "identity"}, False), + ({"Transfer-Encoding": ""}, False), + ], +) +def test_request_declares_body_reads_the_framing_headers(headers, expected): + from api.helpers import request_declares_body + + handler = SimpleNamespace(headers=headers) + + assert request_declares_body(cast(Any, handler)) is expected + + +def test_request_declares_body_is_case_insensitive_on_a_real_message(): + """Production handlers carry an email.message.Message, not a dict.""" + from email.parser import Parser + + from api.helpers import request_declares_body + + message = Parser().parsestr("Host: x\r\ntransfer-encoding: Chunked\r\n\r\n") + + assert request_declares_body(cast(Any, SimpleNamespace(headers=message))) is True + + +def test_request_declares_body_tolerates_a_handler_without_headers(): + """Never raise on a rejection path: an exception there becomes a 500.""" + from api.helpers import request_declares_body + + assert request_declares_body(cast(Any, SimpleNamespace())) is False + assert request_declares_body(cast(Any, SimpleNamespace(headers=None))) is False + assert request_declares_body(cast(Any, SimpleNamespace(headers=object()))) is False + + def test_csp_report_rate_limited_closes_connection(monkeypatch): """A rate-limited CSP report is dropped before its body is read.""" import api.routes as routes @@ -137,6 +251,90 @@ def test_upload_oversize_rejects_before_read(monkeypatch): assert handler.close_connection is True +# ── Multipart framing preflight: chunked uploads must close, empty ones must not ─ +# +# All four multipart handlers keyed on Content-Length alone, so a chunked upload +# (which has none) was read as length 0, matched no parts, and was rejected with +# "No file field in request" and keep-alive intact — leaving every chunk byte on +# the socket, which is the exact poisoning this PR exists to prevent. +# http.server never decodes chunked framing, so the only safe answer is 411 with +# the connection armed for close. + +_MULTIPART_HANDLERS = [ + "handle_upload", + "handle_upload_extract", + "handle_transcribe", + "handle_workspace_upload", +] + + +class _NeverRead: + """An rfile that fails the test if a reject-before-read path reads the body.""" + + def read(self, *_args): + raise AssertionError("the body must not be read on a reject-before-read path") + + +def _run_multipart_handler(monkeypatch, handler_name, headers, rfile): + import api.upload as upload + + answered: dict[str, Any] = {} + + def _record(_handler, payload, status=200): + answered["payload"] = payload + answered["status"] = status + return True + + monkeypatch.setattr(upload, "j", _record) + handler = SimpleNamespace(headers=headers, rfile=rfile, close_connection=False) + + getattr(upload, handler_name)(cast(Any, handler)) + return handler, answered + + +@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) +def test_chunked_multipart_rejected_before_read_closes_connection(handler_name, monkeypatch): + handler, answered = _run_multipart_handler( + monkeypatch, + handler_name, + {"Content-Type": "multipart/form-data; boundary=x", "Transfer-Encoding": "chunked"}, + _NeverRead(), + ) + + assert answered["status"] == 411, answered + assert "Transfer-Encoding" in answered["payload"]["error"], answered + assert handler.close_connection is True + + +@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) +def test_bodyless_multipart_rejection_keeps_connection(handler_name, monkeypatch): + """A well-framed upload with no body is rejected with nothing left unread.""" + handler, answered = _run_multipart_handler( + monkeypatch, + handler_name, + {"Content-Type": "multipart/form-data; boundary=x", "Content-Length": "0"}, + SimpleNamespace(read=lambda *_args: b""), + ) + + assert answered["status"] == 400, answered + assert handler.close_connection is False + + +@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) +def test_non_numeric_content_length_still_closes(handler_name, monkeypatch): + """The pre-existing invalid-Content-Length reject keeps its 400 and its close.""" + handler, answered = _run_multipart_handler( + monkeypatch, + handler_name, + {"Content-Type": "multipart/form-data; boundary=x", "Content-Length": "banana"}, + _NeverRead(), + ) + + assert answered["status"] == 400, answered + assert answered["payload"]["error"] == "Invalid Content-Length", answered + assert handler.close_connection is True + + # ── Header serialization: exactly one Connection: close, never a crash ──────── # # The advertisement lives in Handler.end_headers() via diff --git a/tests/test_rejected_write_pooled_client.py b/tests/test_rejected_write_pooled_client.py index f335a640..d1b348fd 100644 --- a/tests/test_rejected_write_pooled_client.py +++ b/tests/test_rejected_write_pooled_client.py @@ -19,8 +19,11 @@ stale tab gets 410, not 403). """ import http.client +import socket import urllib.parse +import pytest + from tests._pytest_port import BASE _PORT = urllib.parse.urlparse(BASE).port @@ -61,3 +64,96 @@ def test_rejected_write_advertises_close_and_pooled_followup_succeeds(): assert follow_status == 200, ( f"pooled follow-up after a reject-before-read must succeed; got {follow_status}" ) + + +# ── Framing the Content-Length gate could not see ───────────────────────────── +# +# Both cases below are pipelined down ONE socket against the real server: the +# reject-before-read request first, an ordinary GET immediately after it in the +# same write. If the rejection closes, the server answers once and EOFs and the +# GET is never seen. If it does not, the leftover body/chunk bytes are parsed as +# the next request line and a second response comes back — a 400 bad-syntax or +# `501 Unsupported method` naming bytes the client never sent as a request. + +_FOLLOWING_GET = b"GET /api/health/agent HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept: */*\r\n\r\n" +_MULTIPART_UPLOAD_PATHS = ( + "/api/upload", + "/api/upload/extract", + "/api/workspace/upload", + "/api/transcribe", +) +_MULTIPART_PAYLOAD = ( + b'--x\r\nContent-Disposition: form-data; name="file"; filename="a.txt"\r\n' + b"\r\nhello\r\n--x--\r\n" +) +_CHUNKED_MULTIPART_BODY = ( + f"{len(_MULTIPART_PAYLOAD):X}\r\n".encode() + _MULTIPART_PAYLOAD + b"\r\n0\r\n\r\n" +) + + +def _pipelined_after(request: bytes) -> bytes: + """Send *request* and a plain GET in one write; return every byte answered.""" + sock = socket.create_connection(("127.0.0.1", _PORT), timeout=10) + try: + sock.sendall(request + _FOLLOWING_GET) + received = b"" + while True: + try: + chunk = sock.recv(65536) + except (TimeoutError, socket.timeout, ConnectionError): + break + if not chunk: + break + received += chunk + return received + finally: + sock.close() + + +def _assert_single_closed_response(answered: bytes, status: bytes, leftover: bytes) -> None: + text = answered.decode("latin-1", errors="replace") + assert answered.startswith(b"HTTP/1.1 " + status), text + assert b"Connection: close" in answered, text + assert answered.count(b"HTTP/1.1 ") == 1, ( + f"the pipelined GET was answered, so the socket stayed open: {text}" + ) + assert leftover not in answered, f"the unread body reached the request parser: {text}" + assert b"Bad request syntax" not in answered, text + assert b"Unsupported method" not in answered, text + + +@pytest.mark.parametrize("path", _MULTIPART_UPLOAD_PATHS) +def test_chunked_upload_rejection_closes_and_cannot_poison_the_socket(path): + """A chunked upload has no Content-Length — the old gate read it as empty. + + All four multipart handlers then answered 400 "No file field in request" + with keep-alive intact, leaving the chunk bytes on the socket. + """ + answered = _pipelined_after( + f"POST {path} HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Content-Type: multipart/form-data; boundary=x\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n".encode() + _CHUNKED_MULTIPART_BODY + ) + + _assert_single_closed_response(answered, b"411", _MULTIPART_PAYLOAD) + + +def test_sidecar_get_with_a_body_closes_and_cannot_poison_the_socket(): + """A provenance-rejected GET that carries a declared body. + + `read_request_body=False` said "no body" and the 403 went out with + keep-alive; the gate's repro then got `501 Unsupported method ('{}GET')` on + the same socket. No Origin/Referer/Sec-Fetch-Site here, so provenance fails + before the body would ever be read. + """ + answered = _pipelined_after( + "GET /api/extensions/probe/sidecar/ping HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Content-Type: application/json\r\n" + f"Content-Length: {len(_BODY)}\r\n" + "\r\n".encode() + _BODY + ) + + _assert_single_closed_response(answered, b"403", _BODY) From fb7664bc4087dd537341da6ab0deb1fdaa642144 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Sat, 15 Aug 2026 15:37:20 -0300 Subject: [PATCH 42/47] fix(http): read framing from every header value, not just the first Adversarially probing the re-gated head found the same poisoning reachable by DUPLICATING a framing header instead of omitting one. `Message.get()` returns only the first occurrence, so `Content-Length: 0` sent ahead of the real length made the body invisible to every reader: nothing was drained and the real bytes were parsed as the next request line. Reproduced live on the otherwise-fixed head, over one pooled socket: HTTP/1.1 403 Forbidden <- sidecar provenance reject HTTP/1.1 400 Bad request syntax ('{"stale": true}GET /api/health/agent ...') HTTP/1.1 400 Bad Request <- /api/upload reject HTTP/1.1 400 Bad request syntax ('--x') api/helpers.py now reads framing through `_framing_header_values()`, which returns every value the wire carried. `request_declares_body()` treats disagreeing or unparseable lengths as a declared body (they cannot prove the body's absence), and `unsupported_transfer_encoding()` inspects every declared coding so `identity` cannot mask a `chunked` behind it. Two agreeing zeroes are still no body -- repetition alone is not a conflict. New `unreadable_content_length()` gates the multipart preflight: with no honest byte count there is nothing to drain, so all four handlers refuse with 400 "Invalid Content-Length" before touching rfile, connection armed for close. 13 regressions, each verified red without this commit: the helper predicates on a real email.message.Message, the production sidecar 403, all four multipart handlers, and five pipelined real-socket tests proving the follow-up request is never answered off a poisoned socket. --- api/helpers.py | 100 +++++++++++------ api/upload.py | 13 ++- tests/test_rejected_write_connection_close.py | 105 ++++++++++++++++++ tests/test_rejected_write_pooled_client.py | 37 ++++++ 4 files changed, 223 insertions(+), 32 deletions(-) diff --git a/api/helpers.py b/api/helpers.py index 495e4baa..7e9a4ccf 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -223,29 +223,70 @@ def arm_connection_close(handler) -> None: _UNFRAMED_TRANSFER_CODINGS = frozenset({'', 'identity'}) -def _framing_header(handler, name: str) -> str | None: - """Read one framing header, tolerating handler stubs and plain dicts. +def _framing_header_values(handler, name: str) -> list[str]: + """Every value the wire carried for one framing header, tolerating stubs. - A real handler carries an ``email.message.Message``, whose ``get()`` is - case-insensitive like the wire; the suite's ``SimpleNamespace(headers={...})`` + ALL values, not just the first: ``Message.get()`` returns only the first + occurrence, so a request carrying ``Content-Length: 0`` followed by + ``Content-Length: 42`` reads back as length zero. Its 42 body bytes are then + never consumed and get parsed as the next request line -- the same poisoning + this module exists to prevent, reached by duplicating a header rather than by + omitting one. Conflicting framing is only visible if every value is. + + A real handler carries an ``email.message.Message`` (case-insensitive + ``get_all()``, like the wire); the suite's ``SimpleNamespace(headers={...})`` doubles are plain dicts, which are not, so fall back to a case-insensitive scan for those. A missing or odd ``headers`` must never raise: every caller is on a rejection path where an exception becomes a spurious 500. """ headers = getattr(handler, 'headers', None) if headers is None: - return None + return [] try: - value = headers.get(name) - if value is None and isinstance(headers, dict): + get_all = getattr(headers, 'get_all', None) + if callable(get_all): + values = get_all(name) + return [] if values is None else [str(v) for v in values if v is not None] + if isinstance(headers, dict): lowered = name.lower() - for key, candidate in headers.items(): - if str(key).lower() == lowered: - value = candidate - break + return [ + str(v) for k, v in headers.items() + if str(k).lower() == lowered and v is not None + ] + value = headers.get(name) + return [] if value is None else [str(value)] except Exception: - return None - return None if value is None else str(value) + return [] + + +def _declared_content_lengths(handler) -> set[int] | None: + """Distinct declared ``Content-Length`` values, or ``None`` if any is unreadable. + + ``None`` means the framing cannot be trusted at all (a non-numeric value); + an empty set means no length was declared. + """ + parsed: set[int] = set() + for raw in _framing_header_values(handler, 'Content-Length'): + stripped = raw.strip() + if not stripped: + continue + try: + parsed.add(int(stripped)) + except ValueError: + return None + return parsed + + +def unreadable_content_length(handler) -> bool: + """True when ``Content-Length`` cannot be trusted to frame the body. + + Either a value that will not parse, or two or more values that disagree. + Neither can tell a reader how many bytes to drain, so such a request can + only be rejected -- with the connection armed for close, since bytes may + still be queued behind it. + """ + parsed = _declared_content_lengths(handler) + return parsed is None or len(parsed) > 1 def request_declares_body(handler) -> bool: @@ -259,20 +300,16 @@ def request_declares_body(handler) -> bool: "read" requests (whose unread bytes poison the next pooled request) and kills healthy keep-alive on body-less writes. - An unparseable ``Content-Length`` counts as a declared body: a value we - cannot interpret does not prove the body's absence, and the safe assumption - for connection reuse is that bytes are pending. + An unparseable or self-contradicting ``Content-Length`` counts as a declared + body: a value we cannot interpret does not prove the body's absence, and the + safe assumption for connection reuse is that bytes are pending. """ - coding = _framing_header(handler, 'Transfer-Encoding') - if coding is not None and coding.strip().lower() not in _UNFRAMED_TRANSFER_CODINGS: + if unsupported_transfer_encoding(handler) is not None: return True - raw_length = _framing_header(handler, 'Content-Length') - if raw_length is None or not raw_length.strip(): - return False - try: - return int(raw_length.strip()) != 0 - except ValueError: + parsed = _declared_content_lengths(handler) + if parsed is None: return True + return any(length != 0 for length in parsed) def unsupported_transfer_encoding(handler) -> str | None: @@ -284,14 +321,15 @@ def unsupported_transfer_encoding(handler) -> str | None: consumes nothing at all. Such a request can only be rejected -- and only with the connection armed for close, because its chunk bytes are still queued on the socket. + + Every declared coding is inspected, not just the first: a request may repeat + the header, and one undecodable coding anywhere is enough to refuse. """ - coding = _framing_header(handler, 'Transfer-Encoding') - if coding is None: - return None - normalized = coding.strip() - if normalized.lower() in _UNFRAMED_TRANSFER_CODINGS: - return None - return normalized + for raw in _framing_header_values(handler, 'Transfer-Encoding'): + normalized = raw.strip() + if normalized.lower() not in _UNFRAMED_TRANSFER_CODINGS: + return normalized + return None def arm_connection_close_if_body_pending(handler) -> bool: diff --git a/api/upload.py b/api/upload.py index 01a7f3f9..517ce835 100644 --- a/api/upload.py +++ b/api/upload.py @@ -8,7 +8,12 @@ import tempfile from pathlib import Path from api.config import MAX_UPLOAD_BYTES, STATE_DIR -from api.helpers import arm_connection_close, j, unsupported_transfer_encoding +from api.helpers import ( + arm_connection_close, + j, + unreadable_content_length, + unsupported_transfer_encoding, +) from api.models import get_session from api.profiles import _profiles_match, get_active_profile_name as _get_active_profile_name from api.workspace import ( @@ -86,6 +91,10 @@ def _read_multipart_or_reject(handler): * a non-numeric or oversized Content-Length cannot be read either, and ``parse_multipart()`` validates the boundary and the length before its first ``rfile.read()`` — so every raise below is still pre-read. + * two DISAGREEING Content-Length headers read back as whichever came first, + so ``Content-Length: 0`` ahead of the real length made the body invisible + and left every byte of it on the socket. There is no honest length to + drain, so the request is refused before ``rfile`` is touched. """ content_type = handler.headers.get('Content-Type', '') coding = unsupported_transfer_encoding(handler) @@ -94,6 +103,8 @@ def _read_multipart_or_reject(handler): f'Unsupported Transfer-Encoding: {coding} (uploads require a Content-Length)', 411, ) + if unreadable_content_length(handler): + raise _RejectBeforeRead('Invalid Content-Length', 400) try: content_length = int(handler.headers.get('Content-Length', 0) or 0) except (TypeError, ValueError): diff --git a/tests/test_rejected_write_connection_close.py b/tests/test_rejected_write_connection_close.py index 306a4408..29ef8e37 100644 --- a/tests/test_rejected_write_connection_close.py +++ b/tests/test_rejected_write_connection_close.py @@ -212,6 +212,89 @@ def test_request_declares_body_tolerates_a_handler_without_headers(): assert request_declares_body(cast(Any, SimpleNamespace(headers=object()))) is False +# ── A duplicated framing header hides the body from a first-value-only read ──── +# +# `Message.get()` returns only the FIRST occurrence, so `Content-Length: 0` +# ahead of the real length reads back as an empty body: nothing is drained and +# the real bytes are parsed as the next request line. Verified live against the +# running server before the fix -- 403 then +# `400 Bad request syntax ('{"stale": true}GET /api/health/agent HTTP/1.1')`, +# and on /api/upload a 400 then `400 Bad request syntax ('--x')`. + + +def _message_with(raw_headers: str): + """Build the email.message.Message a production handler really carries.""" + from email.parser import Parser + + return SimpleNamespace(headers=Parser().parsestr(raw_headers + "\r\n")) + + +def test_duplicate_conflicting_content_length_declares_a_body(): + from api.helpers import request_declares_body + + handler = _message_with("Host: x\r\nContent-Length: 0\r\nContent-Length: 15\r\n") + + assert request_declares_body(cast(Any, handler)) is True + + +def test_duplicate_agreeing_zero_content_length_declares_no_body(): + """Repetition alone is not a conflict -- two honest zeroes still frame nothing.""" + from api.helpers import request_declares_body + + handler = _message_with("Host: x\r\nContent-Length: 0\r\nContent-Length: 0\r\n") + + assert request_declares_body(cast(Any, handler)) is False + + +def test_repeated_transfer_encoding_is_detected_beyond_the_first_value(): + """`identity` first must not mask a chunked coding behind it.""" + from api.helpers import unsupported_transfer_encoding + + handler = _message_with( + "Host: x\r\nTransfer-Encoding: identity\r\nTransfer-Encoding: chunked\r\n" + ) + + assert unsupported_transfer_encoding(cast(Any, handler)) == "chunked" + + +def test_unreadable_content_length_flags_conflict_and_garbage_only(): + from api.helpers import unreadable_content_length + + assert unreadable_content_length(cast(Any, _message_with("Content-Length: 5\r\n"))) is False + assert unreadable_content_length(cast(Any, _message_with("Host: x\r\n"))) is False + assert unreadable_content_length( + cast(Any, _message_with("Content-Length: 5\r\nContent-Length: 5\r\n")) + ) is False + assert unreadable_content_length( + cast(Any, _message_with("Content-Length: 0\r\nContent-Length: 9\r\n")) + ) is True + assert unreadable_content_length(cast(Any, _message_with("Content-Length: nope\r\n"))) is True + + +def test_sidecar_get_with_duplicate_content_length_closes_connection(monkeypatch): + """The conflicting-framing GET reaches the production provenance rejection.""" + import api.routes as routes + + from email.parser import Parser + + headers = Parser().parsestr("Host: x\r\nContent-Length: 0\r\nContent-Length: 15\r\n\r\n") + handler = SimpleNamespace( + path="/api/extensions/ext1/sidecar/proxy", + command="GET", + headers=headers, + close_connection=False, + ) + monkeypatch.setattr(routes, "_match_extension_sidecar_proxy_path", lambda _path: ("ext1", "/proxy")) + monkeypatch.setattr(routes, "_check_same_origin_browser_request", lambda _handler, **_: False) + monkeypatch.setattr(routes, "j", lambda _handler, _payload, status=200: None) + + routes._handle_extension_sidecar_proxy( + cast(Any, handler), SimpleNamespace(path=handler.path, query=""), "GET" + ) + + assert handler.close_connection is True + + def test_csp_report_rate_limited_closes_connection(monkeypatch): """A rate-limited CSP report is dropped before its body is read.""" import api.routes as routes @@ -335,6 +418,28 @@ def test_non_numeric_content_length_still_closes(handler_name, monkeypatch): assert handler.close_connection is True +@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) +def test_duplicate_content_length_multipart_rejects_before_read(handler_name, monkeypatch): + """Disagreeing lengths give no honest byte count -- refuse without reading. + + Reading the first value (0) made the body invisible: the handler answered + "No file field in request" with keep-alive and left the real multipart bytes + on the socket, where the server parsed them as the next request line. + """ + from email.parser import Parser + + headers = Parser().parsestr( + "Content-Type: multipart/form-data; boundary=x\r\n" + "Content-Length: 0\r\n" + "Content-Length: 88\r\n\r\n" + ) + handler, answered = _run_multipart_handler(monkeypatch, handler_name, headers, _NeverRead()) + + assert answered["status"] == 400, answered + assert answered["payload"]["error"] == "Invalid Content-Length", answered + assert handler.close_connection is True + + # ── Header serialization: exactly one Connection: close, never a crash ──────── # # The advertisement lives in Handler.end_headers() via diff --git a/tests/test_rejected_write_pooled_client.py b/tests/test_rejected_write_pooled_client.py index d1b348fd..e39eae2c 100644 --- a/tests/test_rejected_write_pooled_client.py +++ b/tests/test_rejected_write_pooled_client.py @@ -157,3 +157,40 @@ def test_sidecar_get_with_a_body_closes_and_cannot_poison_the_socket(): ) _assert_single_closed_response(answered, b"403", _BODY) + + +# ── Framing hidden behind a DUPLICATED header ───────────────────────────────── +# +# `Content-Length: 0` sent ahead of the real length: `Message.get()` returns only +# the first value, so every reader saw an empty body and drained nothing. Both +# cases below were reproduced live on the otherwise-fixed head, poisoning the +# socket exactly as the single-header cases used to: +# sidecar GET -> 403, then 400 Bad request syntax ('{"stale": true}GET /...') +# /api/upload -> 400, then 400 Bad request syntax ('--x') + + +def test_duplicate_content_length_sidecar_get_closes_and_cannot_poison_the_socket(): + answered = _pipelined_after( + "GET /api/extensions/probe/sidecar/ping HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Content-Type: application/json\r\n" + "Content-Length: 0\r\n" + f"Content-Length: {len(_BODY)}\r\n" + "\r\n".encode() + _BODY + ) + + _assert_single_closed_response(answered, b"403", _BODY) + + +@pytest.mark.parametrize("path", _MULTIPART_UPLOAD_PATHS) +def test_duplicate_content_length_upload_closes_and_cannot_poison_the_socket(path): + answered = _pipelined_after( + f"POST {path} HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Content-Type: multipart/form-data; boundary=x\r\n" + "Content-Length: 0\r\n" + f"Content-Length: {len(_MULTIPART_PAYLOAD)}\r\n" + "\r\n".encode() + _MULTIPART_PAYLOAD + ) + + _assert_single_closed_response(answered, b"400", _MULTIPART_PAYLOAD) From 79f5b25f5f078198b4a8bc9a70a2b55698228a99 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Sat, 15 Aug 2026 17:34:24 -0300 Subject: [PATCH 43/47] fix(http): treat a blank framing value as a declared body, not an absent one `Content-Length:` with nothing after the colon reads back as '' from `Message.get_all()`, and `_declared_content_lengths()` SKIPPED the empty value. The result was "no length declared", so `request_declares_body()` reported no pending body and the sidecar provenance rejection answered 403 with keep-alive intact -- leaving the payload in rfile for the server to parse as the next request line. Reproduced live, pipelined down one socket: GET /api/extensions/probe/sidecar/ping `Content-Length:` {"stale": true} -> HTTP/1.1 403 Forbidden (no Connection: close) -> HTTP/1.1 400 Bad request syntax ('{"stale": true}GET /api/health/agent HTTP/1.1') A blank value cannot prove a body's absence, so it now joins `banana` and two disagreeing lengths as unreadable framing: bytes are assumed pending and the connection closes. Absence is something only the wire can say, and an absent header already yields no values at all. Three adjacent holes of the same class were found by probing and are fixed by the same rule (each reproduced live with the same bad-syntax 400): * whitespace-only `Content-Length: ` -- `Message` collapses it to the same '', as it does a duplicated blank or a blank paired with a real length. * the four multipart upload handlers: a blank length slipped past `unreadable_content_length()`, reached `int('' or 0)` == 0, and answered 400 "No file field in request" with the payload still queued. Now 400 "Invalid Content-Length" with the connection closed. * blank `Transfer-Encoding:` -- '' sat in `_UNFRAMED_TRANSFER_CODINGS` beside `identity`, so a header naming no coding read as "nothing to frame". A blank coding is undecodable, not unframed; uploads answer 411 and close. The no-body contract is unchanged and pinned in both directions: no length header, a padded zero, two AGREEING zeroes and `identity` all still keep the pooled connection alive. --- api/helpers.py | 52 +++-- tests/test_rejected_write_connection_close.py | 178 +++++++++++++++++- tests/test_rejected_write_pooled_client.py | 98 +++++++++- 3 files changed, 312 insertions(+), 16 deletions(-) diff --git a/api/helpers.py b/api/helpers.py index 7e9a4ccf..0073972f 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -220,7 +220,13 @@ def arm_connection_close(handler) -> None: pass -_UNFRAMED_TRANSFER_CODINGS = frozenset({'', 'identity'}) +_UNFRAMED_TRANSFER_CODINGS = frozenset({'identity'}) + +# What ``unsupported_transfer_encoding()`` reports for a header that declares no +# coding at all (``Transfer-Encoding:``). A placeholder rather than the raw '' so +# the return value is never a falsy non-``None`` — callers test ``is not None``, +# and an empty string would also render as a hole in the rejection message. +_BLANK_TRANSFER_CODING = '(blank)' def _framing_header_values(handler, name: str) -> list[str]: @@ -262,14 +268,25 @@ def _framing_header_values(handler, name: str) -> list[str]: def _declared_content_lengths(handler) -> set[int] | None: """Distinct declared ``Content-Length`` values, or ``None`` if any is unreadable. - ``None`` means the framing cannot be trusted at all (a non-numeric value); - an empty set means no length was declared. + ``None`` means the framing cannot be trusted at all (a value that will not + parse); an empty set means no length was declared. + + A BLANK or whitespace-only value is unreadable, NOT absent. ``Content-Length:`` + with payload bytes behind it declares a body whose size the header refuses to + state; skipping the empty value reported "no length declared", so the + rejection kept the connection alive and those bytes were parsed as the next + request line (403 then ``400 Bad request syntax ('{...}GET /api/... HTTP/1.1')``). + Absence is something only the wire can say, and ``_framing_header_values()`` + already reports a header the request never carried as no value at all -- so + anything that *is* here and does not parse has to be treated as pending bytes, + exactly like ``banana`` or two lengths that disagree. Note ``Message`` collapses + ``Content-Length:`` and ``Content-Length: `` to the same ``''``. """ parsed: set[int] = set() for raw in _framing_header_values(handler, 'Content-Length'): stripped = raw.strip() if not stripped: - continue + return None try: parsed.add(int(stripped)) except ValueError: @@ -280,10 +297,10 @@ def _declared_content_lengths(handler) -> set[int] | None: def unreadable_content_length(handler) -> bool: """True when ``Content-Length`` cannot be trusted to frame the body. - Either a value that will not parse, or two or more values that disagree. - Neither can tell a reader how many bytes to drain, so such a request can - only be rejected -- with the connection armed for close, since bytes may - still be queued behind it. + Either a value that will not parse (``banana``, ``0x5``, or a BLANK one), or + two or more values that disagree. None of them can tell a reader how many + bytes to drain, so such a request can only be rejected -- with the connection + armed for close, since bytes may still be queued behind it. """ parsed = _declared_content_lengths(handler) return parsed is None or len(parsed) > 1 @@ -300,9 +317,12 @@ def request_declares_body(handler) -> bool: "read" requests (whose unread bytes poison the next pooled request) and kills healthy keep-alive on body-less writes. - An unparseable or self-contradicting ``Content-Length`` counts as a declared - body: a value we cannot interpret does not prove the body's absence, and the - safe assumption for connection reuse is that bytes are pending. + A blank, unparseable or self-contradicting ``Content-Length`` counts as a + declared body, as does a blank ``Transfer-Encoding``: a value we cannot + interpret does not prove the body's absence, and the safe assumption for + connection reuse is that bytes are pending. Only framing that positively says + "no body" -- no length header at all, or every declared length agreeing on + zero -- keeps the connection alive. """ if unsupported_transfer_encoding(handler) is not None: return True @@ -324,9 +344,19 @@ def unsupported_transfer_encoding(handler) -> str | None: Every declared coding is inspected, not just the first: a request may repeat the header, and one undecodable coding anywhere is enough to refuse. + + A BLANK ``Transfer-Encoding:`` is undecodable too, and used to read as + "unframed" alongside ``identity``: a header that names no coding cannot tell a + reader whether a body follows or how it is framed, and payload bytes behind it + poisoned the next pooled request exactly as chunked framing did. ``''`` only + ever reached the unframed set from an explicitly empty header -- an absent one + yields no values to loop over -- so treating it as a coding we cannot decode + costs a real client nothing. """ for raw in _framing_header_values(handler, 'Transfer-Encoding'): normalized = raw.strip() + if not normalized: + return _BLANK_TRANSFER_CODING if normalized.lower() not in _UNFRAMED_TRANSFER_CODINGS: return normalized return None diff --git a/tests/test_rejected_write_connection_close.py b/tests/test_rejected_write_connection_close.py index 29ef8e37..e09bf30b 100644 --- a/tests/test_rejected_write_connection_close.py +++ b/tests/test_rejected_write_connection_close.py @@ -173,15 +173,20 @@ def test_bodyless_write_method_rejection_keeps_connection(method, headers, monke ({}, False), ({"Content-Length": "0"}, False), ({"Content-Length": " 0 "}, False), - ({"Content-Length": ""}, False), + ({"Content-Length": ""}, True), + ({"Content-Length": " "}, True), + ({"Content-Length": "\t"}, True), ({"Content-Length": "12"}, True), ({"Content-Length": "-1"}, True), ({"Content-Length": "banana"}, True), + ({"Content-Length": "0x5"}, True), + ({"Content-Length": "+5"}, True), ({"content-length": "7"}, True), ({"Transfer-Encoding": "chunked"}, True), ({"transfer-encoding": "Chunked"}, True), ({"Transfer-Encoding": "identity"}, False), - ({"Transfer-Encoding": ""}, False), + ({"Transfer-Encoding": ""}, True), + ({"Transfer-Encoding": " "}, True), ], ) def test_request_declares_body_reads_the_framing_headers(headers, expected): @@ -295,6 +300,130 @@ def test_sidecar_get_with_duplicate_content_length_closes_connection(monkeypatch assert handler.close_connection is True +# ── A BLANK framing value is unreadable, not absent ─────────────────────────── +# +# `Content-Length:` with nothing after the colon reads back as '' from +# `get_all()`, and the empty value was SKIPPED — so `_declared_content_lengths()` +# reported "no length declared", `request_declares_body()` said no body was +# pending, and the provenance rejection answered 403 with keep-alive intact. +# Reproduced live against the running server on the otherwise-fixed head: +# +# GET /api/extensions/probe/sidecar/ping `Content-Length:` {"stale": true} +# -> HTTP/1.1 403 Forbidden (no Connection: close) +# -> HTTP/1.1 400 Bad request syntax +# ('{"stale": true}GET /api/health/agent HTTP/1.1') +# +# `Message` collapses `Content-Length:`, `Content-Length: ` and +# `Content-Length:\t` to the same '', so every blank spelling poisoned the socket +# identically — as did a blank value duplicated, or paired with a real length. +# +# A blank `Transfer-Encoding:` was the same hole one header over: '' sat in +# `_UNFRAMED_TRANSFER_CODINGS` beside `identity`, so a header naming no coding at +# all read as "no framing to worry about" and its payload poisoned the socket the +# same way (verified live: 403 then the same bad-syntax 400). Absence is +# something only the wire can say, and an absent header yields no values at all. + + +_BLANK_FRAMING_HEADERS = [ + "Content-Length:", + "Content-Length: ", + "Content-Length:\t", + "Content-Length:\r\nContent-Length:", # duplicated blank + "Content-Length:\r\nContent-Length: 15", # blank ahead of a real length + "Content-Length: 15\r\nContent-Length:", # and behind one + "Content-Length: 0\r\nContent-Length:", # blank behind an honest zero + "Transfer-Encoding:", + "Transfer-Encoding: ", + "Transfer-Encoding:\r\nTransfer-Encoding: chunked", +] + + +@pytest.mark.parametrize("raw", _BLANK_FRAMING_HEADERS) +def test_blank_framing_value_declares_a_body(raw): + """On a real email.message.Message, as the production handler carries.""" + from api.helpers import request_declares_body + + handler = _message_with("Host: x\r\n" + raw + "\r\n") + + assert request_declares_body(cast(Any, handler)) is True + + +@pytest.mark.parametrize( + "raw", + [ + "Content-Length:", + "Content-Length: ", + "Content-Length:\r\nContent-Length:", + "Content-Length:\r\nContent-Length: 15", + "Content-Length: 0\r\nContent-Length:", + ], +) +def test_blank_content_length_is_unreadable(raw): + """The upload preflight gate must refuse it too — there is no length to drain.""" + from api.helpers import unreadable_content_length + + assert unreadable_content_length(cast(Any, _message_with("Host: x\r\n" + raw + "\r\n"))) is True + + +def test_blank_transfer_encoding_is_reported_as_an_undecodable_coding(): + """Named, non-empty and never None, so `is not None` callers and the 411 read right.""" + from api.helpers import unsupported_transfer_encoding + + coding = unsupported_transfer_encoding(cast(Any, _message_with("Host: x\r\nTransfer-Encoding:\r\n"))) + + assert coding is not None + assert coding, "an empty return value would be a falsy non-None trap for callers" + assert unsupported_transfer_encoding(cast(Any, _message_with("Host: x\r\n"))) is None + assert unsupported_transfer_encoding( + cast(Any, _message_with("Host: x\r\nTransfer-Encoding: identity\r\n")) + ) is None + + +@pytest.mark.parametrize("raw", _BLANK_FRAMING_HEADERS) +def test_sidecar_get_with_blank_framing_value_closes_connection(raw, monkeypatch): + """The reported case, through the production provenance rejection.""" + import api.routes as routes + + from email.parser import Parser + + handler = SimpleNamespace( + path="/api/extensions/ext1/sidecar/proxy", + command="GET", + headers=Parser().parsestr("Host: x\r\n" + raw + "\r\n\r\n"), + close_connection=False, + ) + monkeypatch.setattr(routes, "_match_extension_sidecar_proxy_path", lambda _path: ("ext1", "/proxy")) + monkeypatch.setattr(routes, "_check_same_origin_browser_request", lambda _handler, **_: False) + monkeypatch.setattr(routes, "j", lambda _handler, _payload, status=200: None) + + routes._handle_extension_sidecar_proxy( + cast(Any, handler), SimpleNamespace(path=handler.path, query=""), "GET" + ) + + assert handler.close_connection is True + + +@pytest.mark.parametrize( + "raw", + [ + "", + "Content-Length: 0\r\n", + "Content-Length: 0 \r\n", + "Content-Length: 0\r\nContent-Length: 0\r\n", + "Transfer-Encoding: identity\r\n", + ], +) +def test_blank_rule_does_not_over_close_a_bodyless_request(raw): + """The other half of the contract: framing that says "no body" keeps keep-alive. + + Two AGREEING zeroes, a padded zero and `identity` are honest statements that + nothing is queued, and none of them may be swept up by the blank rule. + """ + from api.helpers import request_declares_body + + assert request_declares_body(cast(Any, _message_with("Host: x\r\n" + raw))) is False + + def test_csp_report_rate_limited_closes_connection(monkeypatch): """A rate-limited CSP report is dropped before its body is read.""" import api.routes as routes @@ -440,6 +569,51 @@ def test_duplicate_content_length_multipart_rejects_before_read(handler_name, mo assert handler.close_connection is True +@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) +@pytest.mark.parametrize( + "raw", + ["Content-Length:", "Content-Length: ", "Content-Length:\r\nContent-Length: 63"], + ids=["blank", "whitespace-only", "blank-plus-real"], +) +def test_blank_content_length_multipart_rejects_before_read(handler_name, raw, monkeypatch): + """A blank length reached `int('' or 0)` == 0 and read the body as empty. + + All four handlers then answered 400 "No file field in request" with keep-alive + and left the whole multipart payload on the socket. Verified live on the + otherwise-fixed head: 400 then `400 Bad request syntax ('--x')`. + """ + from email.parser import Parser + + headers = Parser().parsestr( + "Content-Type: multipart/form-data; boundary=x\r\n" + raw + "\r\n\r\n" + ) + handler, answered = _run_multipart_handler(monkeypatch, handler_name, headers, _NeverRead()) + + assert answered["status"] == 400, answered + assert answered["payload"]["error"] == "Invalid Content-Length", answered + assert handler.close_connection is True + + +@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) +def test_blank_transfer_encoding_multipart_rejects_before_read(handler_name, monkeypatch): + """`Transfer-Encoding:` names no coding, so it cannot frame a body either. + + It used to read as "unframed" beside `identity` and fell through to the + length-0 read: 400 "No file field in request", keep-alive, and the payload + left on the socket (live: 400 then `400 Bad request syntax ('--x')`). + """ + from email.parser import Parser + + headers = Parser().parsestr( + "Content-Type: multipart/form-data; boundary=x\r\nTransfer-Encoding:\r\n\r\n" + ) + handler, answered = _run_multipart_handler(monkeypatch, handler_name, headers, _NeverRead()) + + assert answered["status"] == 411, answered + assert "Transfer-Encoding" in answered["payload"]["error"], answered + assert handler.close_connection is True + + # ── Header serialization: exactly one Connection: close, never a crash ──────── # # The advertisement lives in Handler.end_headers() via diff --git a/tests/test_rejected_write_pooled_client.py b/tests/test_rejected_write_pooled_client.py index e39eae2c..93d1a878 100644 --- a/tests/test_rejected_write_pooled_client.py +++ b/tests/test_rejected_write_pooled_client.py @@ -91,13 +91,19 @@ _CHUNKED_MULTIPART_BODY = ( ) -def _pipelined_after(request: bytes) -> bytes: - """Send *request* and a plain GET in one write; return every byte answered.""" +def _pipelined_after(request: bytes, *, stop_after: int | None = None) -> bytes: + """Send *request* and a plain GET in one write; return every byte answered. + + Reads to EOF by default, which is the whole point of the closing cases: the + proof is that NOTHING follows the single response. `stop_after` bounds the + read for the keep-alive cases, where the socket stays open by design and + reading to EOF would only burn the timeout. + """ sock = socket.create_connection(("127.0.0.1", _PORT), timeout=10) try: sock.sendall(request + _FOLLOWING_GET) received = b"" - while True: + while stop_after is None or received.count(b"HTTP/1.1 ") < stop_after: try: chunk = sock.recv(65536) except (TimeoutError, socket.timeout, ConnectionError): @@ -194,3 +200,89 @@ def test_duplicate_content_length_upload_closes_and_cannot_poison_the_socket(pat ) _assert_single_closed_response(answered, b"400", _MULTIPART_PAYLOAD) + + +# ── Framing hidden behind a BLANK header value ──────────────────────────────── +# +# `Content-Length:` with nothing after the colon reads back as '' and was skipped +# as "no length declared", so the rejection kept the connection alive. Both cases +# below were reproduced live on the otherwise-fixed head: +# sidecar GET -> 403, then 400 Bad request syntax ('{"stale": true}GET /...') +# /api/upload -> 400, then 400 Bad request syntax ('--x') +# `Message` collapses `Content-Length:` and `Content-Length: ` to the same '', +# and a blank `Transfer-Encoding:` sat in the unframed set beside `identity`, so +# each spelling below poisoned the socket the same way. + +_BLANK_FRAMING_HEADER_LINES = ( + b"Content-Length:\r\n", + b"Content-Length: \r\n", + b"Content-Length:\r\nContent-Length:\r\n", + b"Content-Length: 0\r\nContent-Length:\r\n", + b"Transfer-Encoding:\r\n", +) + + +@pytest.mark.parametrize("framing", _BLANK_FRAMING_HEADER_LINES) +def test_blank_framing_sidecar_get_closes_and_cannot_poison_the_socket(framing): + answered = _pipelined_after( + b"GET /api/extensions/probe/sidecar/ping HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Content-Type: application/json\r\n" + framing + b"\r\n" + _BODY + ) + + _assert_single_closed_response(answered, b"403", _BODY) + + +@pytest.mark.parametrize("path", _MULTIPART_UPLOAD_PATHS) +@pytest.mark.parametrize( + ("framing", "status"), + [ + (b"Content-Length:\r\n", b"400"), + (b"Content-Length: \r\n", b"400"), + (b"Transfer-Encoding:\r\n", b"411"), + ], + ids=["blank-length", "whitespace-only-length", "blank-transfer-encoding"], +) +def test_blank_framing_upload_closes_and_cannot_poison_the_socket(path, framing, status): + answered = _pipelined_after( + f"POST {path} HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Content-Type: multipart/form-data; boundary=x\r\n".encode() + + framing + + b"\r\n" + + _MULTIPART_PAYLOAD + ) + + _assert_single_closed_response(answered, status, _MULTIPART_PAYLOAD) + + +@pytest.mark.parametrize( + "framing", + [ + b"", + b"Content-Length: 0\r\n", + b"Content-Length: 0\r\nContent-Length: 0\r\n", + b"Transfer-Encoding: identity\r\n", + ], + ids=["no-length", "zero-length", "two-agreeing-zeroes", "identity"], +) +def test_bodyless_framing_keeps_the_pooled_socket_alive(framing): + """The over-close half of the contract, on the wire. + + Framing that positively says "no body" must NOT be swept up by the blank + rule: the rejection answers without `Connection: close` and the pipelined GET + is served on the same socket. Two agreeing zeroes are still no body. + """ + answered = _pipelined_after( + b"GET /api/extensions/probe/sidecar/ping HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + framing + b"\r\n", + stop_after=2, + ) + + text = answered.decode("latin-1", errors="replace") + assert answered.startswith(b"HTTP/1.1 403"), text + assert b"HTTP/1.1 200 OK" in answered, ( + f"the pipelined GET was not served, so a body-less rejection dropped a " + f"healthy pooled connection: {text}" + ) + assert b"Connection: close" not in answered, text From 01f2446459b2646ac31902b048d1ee8eb83268a9 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Sat, 15 Aug 2026 18:22:49 -0300 Subject: [PATCH 44/47] fix(http): read a length only when it is 1*DIGIT, not whenever int() copes `_declared_content_lengths()` parsed with `int()`, which is far looser than RFC 9110's `Content-Length = 1*DIGIT`: it accepts a leading sign, PEP 515 underscores and non-ASCII digits, and `str.strip()` ahead of it also erased non-ASCII whitespace. Every such spelling of ZERO therefore parsed to an honest `0`, took the "every declared length agrees on zero" keep-alive branch, and left the payload in rfile for the server to parse as the next request line -- the same defect the blank value had, one layer down. Reproduced live at the previous head, pipelined down one socket: GET /api/extensions/probe/sidecar/ping `Content-Length: +0` {"stale": true} -> HTTP/1.1 403 Forbidden (no Connection: close) -> HTTP/1.1 400 Bad request syntax ('{"stale": true}GET /api/health/agent HTTP/1.1') byte-for-byte the trace greptile filed the P1 for, and identical for `-0`, `0_0`, `+00`, `0_0_0`, `+0_0`, `\xa00`, `0\xa0` and `\x850`. On /api/upload the same values slipped `unreadable_content_length()` into `int('0_0')` == 0 and answered 400 keep-alive then `400 Bad request syntax ('--x')`. A sign was reported safe last round because it was probed as `+15`, where a wrong parse closes anyway; only the zero-valued spellings expose it. The parse is now the grammar: an unsigned run of ASCII digits, or the framing is unreadable and a body is assumed pending. The `int()` guard stays -- a digit run longer than `sys.get_int_max_str_digits()` (4300) raises ValueError too, and a raise on a rejection path becomes a spurious 500. `Transfer-Encoding` gets the same audit. Trimming is now SP/HTAB only, RFC 9110 OWS and nothing else, because `str.strip()` erased U+00A0/U+0085 there too: `Transfer-Encoding: \xa0identity` -- a spelling no token grammar allows -- read back as the `identity` token, so it counted as unframed and poisoned the socket with the same 403-then-400 trace (411 and close on uploads). Both characters reach the wire, since a request line decodes as latin-1. Membership in `_UNFRAMED_TRANSFER_CODINGS` is tested through `str.lower()`, which maps some non-ASCII characters ONTO ASCII ones (U+212A -> `k`), so the set is now documented as ASCII-token-only and a test scans every codepoint to prove no non-ASCII spelling can reach it. Non-ASCII digits are unreadable by the same rule and pinned at the helper level: U+0660/U+FF10 cannot cross an HTTP/1.1 header line (latin-1 leaves only the U+00B2/B3/B9 superscripts, which `int()` already rejects), but the helpers take any headers mapping and enforce the grammar rather than trusting the transport. The no-body contract is unchanged and pinned in both directions on the wire and at the helper: no length header, `0`, `00`, an OWS-padded zero, two AGREEING zeroes, and `identity` in any ASCII case still keep the pooled connection alive. --- api/helpers.py | 44 +++- tests/test_rejected_write_connection_close.py | 233 ++++++++++++++++++ tests/test_rejected_write_pooled_client.py | 76 +++++- 3 files changed, 349 insertions(+), 4 deletions(-) diff --git a/api/helpers.py b/api/helpers.py index 0073972f..64dbafde 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -220,6 +220,13 @@ def arm_connection_close(handler) -> None: pass +# Codings that frame nothing, so a rejection may keep the connection alive. +# Members MUST stay lowercase ASCII tokens: membership is tested through +# ``str.lower()``, which maps some non-ASCII characters ONTO ASCII ones +# (``'K'.lower() == 'k'``), so a coding here containing such a letter would +# be reachable by a spelling no HTTP token grammar allows. ``identity`` has no +# such letter -- ``test_unframed_transfer_codings_cannot_be_spelled_non_ascii`` +# proves it mechanically and will fail if a foldable coding is ever added. _UNFRAMED_TRANSFER_CODINGS = frozenset({'identity'}) # What ``unsupported_transfer_encoding()`` reports for a header that declares no @@ -228,6 +235,14 @@ _UNFRAMED_TRANSFER_CODINGS = frozenset({'identity'}) # and an empty string would also render as a hole in the rejection message. _BLANK_TRANSFER_CODING = '(blank)' +# The only whitespace a field value may be padded with: RFC 9110 OWS is SP/HTAB +# and nothing else. ``str.strip()`` would also erase U+00A0 and U+0085 -- both +# reachable over the wire, because request lines decode as latin-1 -- and +# ``Content-Length: \xa00`` would then read back as a genuine ``0`` while its +# payload sat unread on the socket. Padding a value with a character the grammar +# does not allow makes it unreadable, not zero. +_FIELD_VALUE_OWS = ' \t' + def _framing_header_values(handler, name: str) -> list[str]: """Every value the wire carried for one framing header, tolerating stubs. @@ -281,11 +296,27 @@ def _declared_content_lengths(handler) -> set[int] | None: anything that *is* here and does not parse has to be treated as pending bytes, exactly like ``banana`` or two lengths that disagree. Note ``Message`` collapses ``Content-Length:`` and ``Content-Length: `` to the same ``''``. + + "Parses" means RFC 9110's ``Content-Length = 1*DIGIT``: an unsigned run of + ASCII digits, nothing else. ``int()`` is far more generous than the grammar -- + it accepts a leading sign, PEP 515 underscores and non-ASCII digits -- and + every such spelling of ZERO used to read back as an honest ``0`` and take the + keep-alive branch below with its payload still queued: ``Content-Length: +0`` + (also ``-0``, ``0_0``, ``+00``, ``\\xa00``) answered 403 with no + ``Connection: close``, then ``400 Bad request syntax ('{...}GET /api/...')`` + -- the same trace as a blank value. A sign is only invisible on a NON-zero + value, where the wrong parse happens to close anyway. Anything the grammar + rejects is unreadable framing, which means a body may be pending. + + The ``int()`` guard stays even though ``isdigit()`` has already vetted every + character: a digit run longer than ``sys.get_int_max_str_digits()`` (4300) + raises ``ValueError`` too, and this runs on rejection paths where an + exception becomes a spurious 500. """ parsed: set[int] = set() for raw in _framing_header_values(handler, 'Content-Length'): - stripped = raw.strip() - if not stripped: + stripped = raw.strip(_FIELD_VALUE_OWS) + if not (stripped.isascii() and stripped.isdigit()): return None try: parsed.add(int(stripped)) @@ -352,9 +383,16 @@ def unsupported_transfer_encoding(handler) -> str | None: ever reached the unframed set from an explicitly empty header -- an absent one yields no values to loop over -- so treating it as a coding we cannot decode costs a real client nothing. + + Only SP/HTAB is trimmed, for the same reason the length parse trims only + those: ``str.strip()`` erases U+00A0/U+0085 as well, so ``\\xa0identity`` -- + which no token grammar allows -- read back as plain ``identity`` and its + payload poisoned the next pooled request (403 with no ``Connection: close``, + then the bad-syntax 400). A coding padded with a character the grammar does + not permit is a coding we cannot decode. """ for raw in _framing_header_values(handler, 'Transfer-Encoding'): - normalized = raw.strip() + normalized = raw.strip(_FIELD_VALUE_OWS) if not normalized: return _BLANK_TRANSFER_CODING if normalized.lower() not in _UNFRAMED_TRANSFER_CODINGS: diff --git a/tests/test_rejected_write_connection_close.py b/tests/test_rejected_write_connection_close.py index e09bf30b..8fdfd7bf 100644 --- a/tests/test_rejected_write_connection_close.py +++ b/tests/test_rejected_write_connection_close.py @@ -172,7 +172,9 @@ def test_bodyless_write_method_rejection_keeps_connection(method, headers, monke [ ({}, False), ({"Content-Length": "0"}, False), + ({"Content-Length": "00"}, False), # 1*DIGIT, so an honest zero ({"Content-Length": " 0 "}, False), + ({"Content-Length": "0\t"}, False), ({"Content-Length": ""}, True), ({"Content-Length": " "}, True), ({"Content-Length": "\t"}, True), @@ -181,12 +183,27 @@ def test_bodyless_write_method_rejection_keeps_connection(method, headers, monke ({"Content-Length": "banana"}, True), ({"Content-Length": "0x5"}, True), ({"Content-Length": "+5"}, True), + # int() is looser than 1*DIGIT, and every one of these reads as ZERO -- + # where the wrong parse is not merely wrong but silently keep-alive. + ({"Content-Length": "+0"}, True), + ({"Content-Length": "-0"}, True), + ({"Content-Length": "0_0"}, True), + ({"Content-Length": "+00"}, True), + ({"Content-Length": "0_0_0"}, True), + ({"Content-Length": "٠0"}, True), # ARABIC-INDIC ZERO ahead of an ASCII one + ({"Content-Length": "٠"}, True), # ARABIC-INDIC DIGIT ZERO: int() -> 0 + ({"Content-Length": "0"}, True), # FULLWIDTH DIGIT ZERO: int() -> 0 + ({"Content-Length": "٥"}, True), # ARABIC-INDIC DIGIT FIVE: int() -> 5 + ({"Content-Length": "\xa00"}, True), # NBSP-padded zero: int() strips it + ({"Content-Length": "0\x85"}, True), # NEL-padded zero: likewise ({"content-length": "7"}, True), ({"Transfer-Encoding": "chunked"}, True), ({"transfer-encoding": "Chunked"}, True), ({"Transfer-Encoding": "identity"}, False), + ({"Transfer-Encoding": "IDENTITY "}, False), # a token is case-insensitive ({"Transfer-Encoding": ""}, True), ({"Transfer-Encoding": " "}, True), + ({"Transfer-Encoding": "\xa0identity"}, True), # not the token `identity` ], ) def test_request_declares_body_reads_the_framing_headers(headers, expected): @@ -424,6 +441,179 @@ def test_blank_rule_does_not_over_close_a_bodyless_request(raw): assert request_declares_body(cast(Any, _message_with("Host: x\r\n" + raw))) is False +# ── A length that only `int()` calls zero is unreadable, not zero ────────────── +# +# The blank rule above closed the "no value" hole; this one closes the "value +# `int()` is too generous about" hole beside it. RFC 9110 is +# `Content-Length = 1*DIGIT` -- an unsigned run of ASCII digits -- but `int()` +# also accepts a leading sign, PEP 515 underscores and non-ASCII digits, and +# strips non-ASCII whitespace. Every spelling of ZERO below therefore parsed to +# an honest `0`, took the "every declared length agrees on zero" keep-alive +# branch, and left the payload queued. Reproduced live on the otherwise-fixed +# head, pipelined down one socket: +# +# GET /api/extensions/probe/sidecar/ping `Content-Length: +0` {"stale": true} +# -> HTTP/1.1 403 Forbidden (no Connection: close) +# -> HTTP/1.1 400 Bad request syntax +# ('{"stale": true}GET /api/health/agent HTTP/1.1') +# +# byte-for-byte the trace the blank value produced, and on /api/upload a 400 +# then `400 Bad request syntax ('--x')`. +# +# A sign is only harmless on a NON-zero value (`+5` parses to 5, declares a body +# and closes anyway), which is exactly why the family looked safe: the mistake is +# invisible until the value is zero. +# +# Non-ASCII digits are unreadable by the same rule. An HTTP/1.1 request line +# decodes as latin-1, so U+0660 cannot cross the wire in a header value (only +# U+00B2/U+00B3/U+00B9 -- superscripts, which `int()` already rejects -- and the +# U+0085/U+00A0 whitespace `int()` strips can); these helpers take any headers +# mapping, though, so the grammar is enforced here rather than relying on the +# transport to filter. + +_MALFORMED_ZERO_LENGTHS = [ + "+0", + "-0", + "0_0", + "+00", + "0_0_0", + "\xa00", # NBSP-padded zero: str.strip()/int() erase U+00A0 + "0\x85", # NEL-padded zero: likewise U+0085 + "٠", # ARABIC-INDIC DIGIT ZERO -- isdigit() and int() both accept it + "0", # FULLWIDTH DIGIT ZERO + "𝟢", # MATHEMATICAL MONOSPACE DIGIT ZERO +] + + +@pytest.mark.parametrize("value", _MALFORMED_ZERO_LENGTHS) +def test_malformed_zero_content_length_declares_a_body(value): + """On a real email.message.Message, as the production handler carries.""" + from api.helpers import request_declares_body + + handler = _message_with(f"Host: x\r\nContent-Length: {value}\r\n") + + assert request_declares_body(cast(Any, handler)) is True + + +@pytest.mark.parametrize("value", _MALFORMED_ZERO_LENGTHS) +def test_malformed_zero_content_length_is_unreadable(value): + """The upload preflight gate must refuse it too -- it is not a length.""" + from api.helpers import unreadable_content_length + + handler = _message_with(f"Host: x\r\nContent-Length: {value}\r\n") + + assert unreadable_content_length(cast(Any, handler)) is True + + +@pytest.mark.parametrize("value", _MALFORMED_ZERO_LENGTHS) +def test_sidecar_get_with_a_malformed_zero_length_closes_connection(value, monkeypatch): + """Through the production provenance rejection, not just the helper.""" + handler = _rejected_sidecar_call( + monkeypatch, "GET", _message_with(f"Host: x\r\nContent-Length: {value}\r\n").headers + ) + + assert handler.close_connection is True + + +@pytest.mark.parametrize( + "value", + ["0", "00", "000", " 0 ", "0\t", "\t0", "0\r\nContent-Length: 0"], + ids=["zero", "double-zero", "triple-zero", "padded", "tab-behind", "tab-ahead", "two-agreeing"], +) +def test_digit_grammar_does_not_over_close_an_honest_zero(value): + """The over-close half: `1*DIGIT` zeroes, SP/HTAB padding and all, stay alive. + + `00` is as valid as `0` (`1*DIGIT` sets no leading-zero rule), and RFC 9110 + OWS around a field value is SP/HTAB -- so trimming those must not become + trimming everything `str.strip()` would. + """ + from api.helpers import request_declares_body, unreadable_content_length + + handler = _message_with(f"Host: x\r\nContent-Length: {value}\r\n") + + assert request_declares_body(cast(Any, handler)) is False + assert unreadable_content_length(cast(Any, handler)) is False + + +def test_absurdly_long_digit_run_is_unreadable_and_never_raises(): + """`1*DIGIT` is necessary but not sufficient: `int()` caps the digit count. + + A run longer than `sys.get_int_max_str_digits()` (4300) raises ValueError, and + this runs on rejection paths where an exception becomes a spurious 500 -- so + the parse stays guarded even though every character is now vetted first. + """ + from api.helpers import request_declares_body, unreadable_content_length + + handler = _message_with("Host: x\r\nContent-Length: " + "0" * 5000 + "\r\n") + + assert request_declares_body(cast(Any, handler)) is True + assert unreadable_content_length(cast(Any, handler)) is True + + +# ── The same audit one header over: spelling an undecodable coding ───────────── + + +@pytest.mark.parametrize( + "value", + ["\xa0identity", "identity\xa0", "\xa0identity\xa0", "\x85identity"], + ids=["nbsp-ahead", "nbsp-behind", "nbsp-both", "nel-ahead"], +) +def test_non_ascii_padded_identity_is_not_the_identity_coding(value): + """`str.strip()` erased U+00A0/U+0085, so `\\xa0identity` read back as `identity`. + + No token grammar allows the padding, so the value names no coding this server + can decode -- and it reached the wire: latin-1 request lines carry U+00A0 + fine. Live on the otherwise-fixed head, sidecar GET + payload: 403 with no + `Connection: close`, then `400 Bad request syntax ('{"stale": true}GET ...')`. + """ + from api.helpers import request_declares_body, unsupported_transfer_encoding + + handler = _message_with(f"Host: x\r\nTransfer-Encoding: {value}\r\n") + + assert unsupported_transfer_encoding(cast(Any, handler)) is not None + assert request_declares_body(cast(Any, handler)) is True + + +@pytest.mark.parametrize( + "value", ["identity", "IDENTITY", "Identity", "identity ", "\tidentity\t"] +) +def test_ascii_case_and_ows_spellings_of_identity_stay_decodable(value): + """The over-close half of the coding audit: a real token still frames nothing.""" + from api.helpers import unsupported_transfer_encoding + + handler = _message_with(f"Host: x\r\nTransfer-Encoding: {value}\r\n") + + assert unsupported_transfer_encoding(cast(Any, handler)) is None + + +def test_unframed_transfer_codings_cannot_be_spelled_non_ascii(): + """Pin WHY membership-by-`str.lower()` is safe, so a future coding can't break it. + + `str.lower()` maps some non-ASCII characters onto ASCII ones -- U+212A KELVIN + SIGN lowercases to `k`, so `'chunKed'.lower() == 'chunked'` -- which means a + coding in the unframed set containing such a letter would be reachable by a + spelling no HTTP token allows, and would read as "frames nothing" for a + request whose bytes are still queued. `identity` has no such letter. This + scans every codepoint rather than trusting the claim. + """ + from api.helpers import _UNFRAMED_TRANSFER_CODINGS + + letters = set("".join(_UNFRAMED_TRANSFER_CODINGS)) + assert all( + coding.isascii() and coding.islower() for coding in _UNFRAMED_TRANSFER_CODINGS + ), _UNFRAMED_TRANSFER_CODINGS + foldable = [ + (hex(cp), chr(cp).lower()) + for cp in range(0x110000) + if not chr(cp).isascii() and chr(cp).lower() and set(chr(cp).lower()) <= letters + ] + assert not foldable, ( + f"a non-ASCII character now lowercases into {sorted(letters)}, so an " + f"unframed coding could be spelled outside the token grammar: {foldable}" + ) + assert "chunKed".lower() == "chunked", "the fold this test guards against" + + def test_csp_report_rate_limited_closes_connection(monkeypatch): """A rate-limited CSP report is dropped before its body is read.""" import api.routes as routes @@ -594,6 +784,49 @@ def test_blank_content_length_multipart_rejects_before_read(handler_name, raw, m assert handler.close_connection is True +@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) +@pytest.mark.parametrize("value", ["+0", "0_0", "\xa00", "٠"], ids=["sign", "underscore", "nbsp", "arabic-indic"]) +def test_malformed_zero_content_length_multipart_rejects_before_read( + handler_name, value, monkeypatch +): + """A length only `int()` calls zero slipped the gate into `int(...)` == 0. + + All four handlers then read an empty body, answered 400 "No file field in + request" with keep-alive, and left the whole payload on the socket. Live on + the otherwise-fixed head: 400 then `400 Bad request syntax ('--x')`. + """ + from email.parser import Parser + + headers = Parser().parsestr( + f"Content-Type: multipart/form-data; boundary=x\r\nContent-Length: {value}\r\n\r\n" + ) + handler, answered = _run_multipart_handler(monkeypatch, handler_name, headers, _NeverRead()) + + assert answered["status"] == 400, answered + assert answered["payload"]["error"] == "Invalid Content-Length", answered + assert handler.close_connection is True + + +@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) +def test_non_ascii_padded_identity_multipart_rejects_before_read(handler_name, monkeypatch): + """`Transfer-Encoding: \\xa0identity` is not the `identity` token either. + + `str.strip()` erased the U+00A0, so it read as unframed, fell through to the + length-0 read and answered 400 "No file field in request" with the payload + still queued (live: 400 then `400 Bad request syntax ('--x')`). + """ + from email.parser import Parser + + headers = Parser().parsestr( + "Content-Type: multipart/form-data; boundary=x\r\nTransfer-Encoding: \xa0identity\r\n\r\n" + ) + handler, answered = _run_multipart_handler(monkeypatch, handler_name, headers, _NeverRead()) + + assert answered["status"] == 411, answered + assert "Transfer-Encoding" in answered["payload"]["error"], answered + assert handler.close_connection is True + + @pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) def test_blank_transfer_encoding_multipart_rejects_before_read(handler_name, monkeypatch): """`Transfer-Encoding:` names no coding, so it cannot frame a body either. diff --git a/tests/test_rejected_write_pooled_client.py b/tests/test_rejected_write_pooled_client.py index 93d1a878..d343a863 100644 --- a/tests/test_rejected_write_pooled_client.py +++ b/tests/test_rejected_write_pooled_client.py @@ -256,15 +256,89 @@ def test_blank_framing_upload_closes_and_cannot_poison_the_socket(path, framing, _assert_single_closed_response(answered, status, _MULTIPART_PAYLOAD) +# ── Framing only `int()` reads as zero ──────────────────────────────────────── +# +# RFC 9110 is `Content-Length = 1*DIGIT`; `int()` also takes a sign, PEP 515 +# underscores and non-ASCII whitespace padding, so each value below parsed to an +# honest `0`, took the "every declared length agrees on zero" keep-alive branch +# and left the payload queued. Reproduced live on the otherwise-fixed head: +# sidecar GET -> 403 (no Connection: close), then +# 400 Bad request syntax ('{"stale": true}GET /api/health/agent HTTP/1.1') +# /api/upload -> 400, then 400 Bad request syntax ('--x') +# `Transfer-Encoding: \xa0identity` is the same mistake one header over: strip() +# erased the U+00A0 and it read back as the `identity` token. Everything here is +# wire-reachable -- a request line decodes as latin-1, so U+00A0 and U+0085 pass +# through untouched (a non-ASCII DIGIT such as U+0660 cannot, which is why that +# family is pinned at the helper level in test_rejected_write_connection_close.py). + +_MALFORMED_ZERO_FRAMING_LINES = [ + b"Content-Length: +0\r\n", + b"Content-Length: -0\r\n", + b"Content-Length: 0_0\r\n", + b"Content-Length: +00\r\n", + b"Content-Length: 0_0_0\r\n", + b"Content-Length: \xa00\r\n", + b"Content-Length: 0\x85\r\n", + b"Content-Length: 0\r\nContent-Length: +0\r\n", + b"Transfer-Encoding: \xa0identity\r\n", +] + + +@pytest.mark.parametrize("framing", _MALFORMED_ZERO_FRAMING_LINES) +def test_malformed_zero_framing_sidecar_get_closes_and_cannot_poison_the_socket(framing): + answered = _pipelined_after( + b"GET /api/extensions/probe/sidecar/ping HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Content-Type: application/json\r\n" + framing + b"\r\n" + _BODY + ) + + _assert_single_closed_response(answered, b"403", _BODY) + + +@pytest.mark.parametrize("path", _MULTIPART_UPLOAD_PATHS) +@pytest.mark.parametrize( + ("framing", "status"), + [ + (b"Content-Length: +0\r\n", b"400"), + (b"Content-Length: 0_0\r\n", b"400"), + (b"Content-Length: \xa00\r\n", b"400"), + (b"Transfer-Encoding: \xa0identity\r\n", b"411"), + ], + ids=["sign", "underscore", "nbsp-padded", "nbsp-padded-identity"], +) +def test_malformed_zero_framing_upload_closes_and_cannot_poison_the_socket(path, framing, status): + answered = _pipelined_after( + f"POST {path} HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Content-Type: multipart/form-data; boundary=x\r\n".encode() + + framing + + b"\r\n" + + _MULTIPART_PAYLOAD + ) + + _assert_single_closed_response(answered, status, _MULTIPART_PAYLOAD) + + @pytest.mark.parametrize( "framing", [ b"", b"Content-Length: 0\r\n", + b"Content-Length: 00\r\n", + b"Content-Length: 0 \r\n", b"Content-Length: 0\r\nContent-Length: 0\r\n", b"Transfer-Encoding: identity\r\n", + b"Transfer-Encoding: IDENTITY \r\n", + ], + ids=[ + "no-length", + "zero-length", + "double-zero-length", + "ows-padded-zero", + "two-agreeing-zeroes", + "identity", + "identity-cased-and-padded", ], - ids=["no-length", "zero-length", "two-agreeing-zeroes", "identity"], ) def test_bodyless_framing_keeps_the_pooled_socket_alive(framing): """The over-close half of the contract, on the wire. From b022a79eef046a2dc5d1613721491a8b7ea21c70 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Sat, 15 Aug 2026 20:20:36 -0300 Subject: [PATCH 45/47] fix(http): reject every Transfer-Encoding, not just the ones we can't name `identity` was exempt as a coding that "frames nothing", so a request carrying it read back as body-less however many payload bytes followed. It frames nothing either: RFC 9112 6.3 says a request whose final transfer coding is not `chunked` has a body length no recipient can determine, RFC 7230 removed `identity` from the transfer codings outright, and `http.server` decodes none of them (`rfile` is the raw socket). Every present value has to reject-and-close. Reproduced on 8c326003b, pipelined down one socket: GET /api/extensions/probe/sidecar/ping `Transfer-Encoding: identity` {"stale": true} -> HTTP/1.1 403 Forbidden (no Connection: close) -> HTTP/1.1 400 Bad request syntax ('{"stale": true}GET /api/health/agent HTTP/1.1') POST /api/upload (and /upload/extract, /workspace/upload, /transcribe) -> HTTP/1.1 400 Bad Request ("No file field in request", no close) -> HTTP/1.1 400 Bad request syntax ('--x') Deletes the `_UNFRAMED_TRANSFER_CODINGS` allowlist and the fold-safety test that existed only to guard its membership check; `_BLANK_TRANSFER_CODING` stays, since a present-but-empty header still needs a named, non-falsy report. The identity rows of the framing tables and of `test_bodyless_framing_keeps_the_pooled_socket_alive` move to the closes-the-socket side, with real-socket coverage on the sidecar path and all four upload endpoints. The keep-alive half stays pinned by the cases that genuinely have no body: no framing header at all, `Content-Length: 0`, an OWS-padded zero, two agreeing zeroes. Swept the rest of the coding surface for the same class: every case spelling, OWS and non-OWS padding, `identity, chunked`, `chunked, identity`, `identity,identity`, a lone comma, an unknown coding, duplicated headers, and a coding beside both a real and a zero Content-Length. --- api/helpers.py | 76 +++--- api/upload.py | 18 +- tests/test_rejected_write_connection_close.py | 237 +++++++++++------- tests/test_rejected_write_pooled_client.py | 92 ++++++- 4 files changed, 281 insertions(+), 142 deletions(-) diff --git a/api/helpers.py b/api/helpers.py index 64dbafde..9de52ba5 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -220,15 +220,6 @@ def arm_connection_close(handler) -> None: pass -# Codings that frame nothing, so a rejection may keep the connection alive. -# Members MUST stay lowercase ASCII tokens: membership is tested through -# ``str.lower()``, which maps some non-ASCII characters ONTO ASCII ones -# (``'K'.lower() == 'k'``), so a coding here containing such a letter would -# be reachable by a spelling no HTTP token grammar allows. ``identity`` has no -# such letter -- ``test_unframed_transfer_codings_cannot_be_spelled_non_ascii`` -# proves it mechanically and will fail if a foldable coding is ever added. -_UNFRAMED_TRANSFER_CODINGS = frozenset({'identity'}) - # What ``unsupported_transfer_encoding()`` reports for a header that declares no # coding at all (``Transfer-Encoding:``). A placeholder rather than the raw '' so # the return value is never a falsy non-``None`` — callers test ``is not None``, @@ -349,11 +340,11 @@ def request_declares_body(handler) -> bool: kills healthy keep-alive on body-less writes. A blank, unparseable or self-contradicting ``Content-Length`` counts as a - declared body, as does a blank ``Transfer-Encoding``: a value we cannot - interpret does not prove the body's absence, and the safe assumption for - connection reuse is that bytes are pending. Only framing that positively says - "no body" -- no length header at all, or every declared length agreeing on - zero -- keeps the connection alive. + declared body, as does ANY ``Transfer-Encoding`` -- ``identity`` and a blank + value included: a value we cannot interpret does not prove the body's absence, + and the safe assumption for connection reuse is that bytes are pending. Only + framing that positively says "no body" -- no framing header at all, or every + declared length agreeing on zero -- keeps the connection alive. """ if unsupported_transfer_encoding(handler) is not None: return True @@ -366,38 +357,39 @@ def request_declares_body(handler) -> bool: def unsupported_transfer_encoding(handler) -> str | None: """Return the transfer coding this server cannot decode, or ``None``. - ``BaseHTTPRequestHandler`` never decodes ``Transfer-Encoding``: it hands - every reader a raw ``rfile``, so a chunked body reads back as its literal - chunk framing while a ``Content-Length``-based reader sees length 0 and - consumes nothing at all. Such a request can only be rejected -- and only - with the connection armed for close, because its chunk bytes are still - queued on the socket. + EVERY present value counts, with no exemption for any coding. + ``BaseHTTPRequestHandler`` decodes none of them: it hands every reader a raw + ``rfile``, so a chunked body reads back as its literal chunk framing while a + ``Content-Length``-based reader sees length 0 and consumes nothing at all. + RFC 9112 section 6.3 says the same thing normatively -- a request whose final + transfer coding is not ``chunked`` has a body length the recipient cannot + determine, so it must be refused and the connection closed. Such a request + can therefore only be rejected, and only with the connection armed for close, + because its payload bytes are still queued on the socket. - Every declared coding is inspected, not just the first: a request may repeat - the header, and one undecodable coding anywhere is enough to refuse. + ``identity`` used to be exempt as a coding that "frames nothing". It is not: + it names no framing either, and RFC 7230 removed it from the transfer codings + altogether. A sidecar rejection then answered 403 with keep-alive and the + payload was parsed as the next request line + (``400 Bad request syntax ('{...}GET /api/health/agent HTTP/1.1')``), and all + four upload handlers fell through to a length-0 read, answered 400 "No file + field in request" and left the multipart bytes on the socket + (``400 Bad request syntax ('--x')``). Nothing legitimate is lost: no client + sends a transfer coding this server could have honoured. - A BLANK ``Transfer-Encoding:`` is undecodable too, and used to read as - "unframed" alongside ``identity``: a header that names no coding cannot tell a - reader whether a body follows or how it is framed, and payload bytes behind it - poisoned the next pooled request exactly as chunked framing did. ``''`` only - ever reached the unframed set from an explicitly empty header -- an absent one - yields no values to loop over -- so treating it as a coding we cannot decode - costs a real client nothing. + A header present but EMPTY (``Transfer-Encoding:``) is reported through + ``_BLANK_TRANSFER_CODING`` rather than as ``''``, so callers testing + ``is not None`` and the 411 message both read right. Only an ABSENT header + yields no values at all -- absence is something only the wire can say. - Only SP/HTAB is trimmed, for the same reason the length parse trims only - those: ``str.strip()`` erases U+00A0/U+0085 as well, so ``\\xa0identity`` -- - which no token grammar allows -- read back as plain ``identity`` and its - payload poisoned the next pooled request (403 with no ``Connection: close``, - then the bad-syntax 400). A coding padded with a character the grammar does - not permit is a coding we cannot decode. + The first value is enough precisely because no value is acceptable; it is + reported as-is (SP/HTAB trimmed, the one padding RFC 9110 allows) purely so + the rejection message names what arrived. """ - for raw in _framing_header_values(handler, 'Transfer-Encoding'): - normalized = raw.strip(_FIELD_VALUE_OWS) - if not normalized: - return _BLANK_TRANSFER_CODING - if normalized.lower() not in _UNFRAMED_TRANSFER_CODINGS: - return normalized - return None + values = _framing_header_values(handler, 'Transfer-Encoding') + if not values: + return None + return values[0].strip(_FIELD_VALUE_OWS) or _BLANK_TRANSFER_CODING def arm_connection_close_if_body_pending(handler) -> bool: diff --git a/api/upload.py b/api/upload.py index 517ce835..4174b389 100644 --- a/api/upload.py +++ b/api/upload.py @@ -82,12 +82,15 @@ def _read_multipart_or_reject(handler): Framing is judged from the headers, not from a present ``Content-Length``: - * ``Transfer-Encoding: chunked`` carries no Content-Length, so the old code - read it as length 0, found no parts, and answered "No file field in - request" with keep-alive intact -- leaving every chunk byte on the socket - to poison the next pooled request. ``http.server`` cannot decode chunked - framing at all (``rfile`` is the raw socket), so 411 is the only honest - answer. + * ANY ``Transfer-Encoding`` is refused, ``identity`` included: ``http.server`` + decodes none of them (``rfile`` is the raw socket) and RFC 9112 section 6.3 + says a request whose final coding is not ``chunked`` has a length no + recipient can determine. A coded request carries no usable Content-Length, + so the old code read it as length 0, found no parts, and answered "No file + field in request" with keep-alive intact -- leaving every payload byte on + the socket to poison the next pooled request. A Content-Length sent BESIDE a + coding does not rescue it: that pairing is the smuggling shape 6.3 rejects, + and the coding still hides how the body is framed. * a non-numeric or oversized Content-Length cannot be read either, and ``parse_multipart()`` validates the boundary and the length before its first ``rfile.read()`` — so every raise below is still pre-read. @@ -100,7 +103,8 @@ def _read_multipart_or_reject(handler): coding = unsupported_transfer_encoding(handler) if coding is not None: raise _RejectBeforeRead( - f'Unsupported Transfer-Encoding: {coding} (uploads require a Content-Length)', + f'Unsupported Transfer-Encoding: {coding} ' + '(uploads must send an undecoded body with a Content-Length)', 411, ) if unreadable_content_length(handler): diff --git a/tests/test_rejected_write_connection_close.py b/tests/test_rejected_write_connection_close.py index 8fdfd7bf..f5e80ce5 100644 --- a/tests/test_rejected_write_connection_close.py +++ b/tests/test_rejected_write_connection_close.py @@ -199,11 +199,22 @@ def test_bodyless_write_method_rejection_keeps_connection(method, headers, monke ({"content-length": "7"}, True), ({"Transfer-Encoding": "chunked"}, True), ({"transfer-encoding": "Chunked"}, True), - ({"Transfer-Encoding": "identity"}, False), - ({"Transfer-Encoding": "IDENTITY "}, False), # a token is case-insensitive + # No coding is exempt: http.server decodes none of them (RFC 9112 6.3). + ({"Transfer-Encoding": "identity"}, True), + ({"Transfer-Encoding": "IDENTITY "}, True), + ({"Transfer-Encoding": "Identity"}, True), + ({"Transfer-Encoding": "\tidentity\t"}, True), + ({"Transfer-Encoding": "identity, chunked"}, True), + ({"Transfer-Encoding": "chunked, identity"}, True), + ({"Transfer-Encoding": "identity,identity"}, True), + ({"Transfer-Encoding": ","}, True), + ({"Transfer-Encoding": "banana"}, True), ({"Transfer-Encoding": ""}, True), ({"Transfer-Encoding": " "}, True), ({"Transfer-Encoding": "\xa0identity"}, True), # not the token `identity` + # A coding beside a length: the coding decides, whatever the length says. + ({"Transfer-Encoding": "identity", "Content-Length": "0"}, True), + ({"Transfer-Encoding": "identity", "Content-Length": "15"}, True), ], ) def test_request_declares_body_reads_the_framing_headers(headers, expected): @@ -268,15 +279,31 @@ def test_duplicate_agreeing_zero_content_length_declares_no_body(): assert request_declares_body(cast(Any, handler)) is False -def test_repeated_transfer_encoding_is_detected_beyond_the_first_value(): - """`identity` first must not mask a chunked coding behind it.""" - from api.helpers import unsupported_transfer_encoding +@pytest.mark.parametrize( + "raw", + [ + "Transfer-Encoding: identity\r\nTransfer-Encoding: chunked\r\n", + "Transfer-Encoding: chunked\r\nTransfer-Encoding: identity\r\n", + "Transfer-Encoding: identity\r\nTransfer-Encoding: identity\r\n", + "Transfer-Encoding: identity\r\ntransfer-encoding: identity\r\n", + ], + ids=["identity-then-chunked", "chunked-then-identity", "twice", "twice-mixed-case"], +) +def test_repeated_transfer_encoding_refuses_whatever_the_order(raw): + """A duplicated header cannot hide an undecodable coding behind an "unframed" one. - handler = _message_with( - "Host: x\r\nTransfer-Encoding: identity\r\nTransfer-Encoding: chunked\r\n" - ) + This used to have to look PAST a leading `identity` to find the chunked coding + (`Message.get()` returns only the first value, so it read as unframed and the + chunk bytes stayed queued). With no coding exempt the leading value already + refuses, so no ordering can mask anything -- which is what is pinned here, + rather than the specific coding a first-value read happened to miss. + """ + from api.helpers import request_declares_body, unsupported_transfer_encoding - assert unsupported_transfer_encoding(cast(Any, handler)) == "chunked" + handler = _message_with("Host: x\r\n" + raw) + + assert unsupported_transfer_encoding(cast(Any, handler)) is not None + assert request_declares_body(cast(Any, handler)) is True def test_unreadable_content_length_flags_conflict_and_garbage_only(): @@ -334,11 +361,12 @@ def test_sidecar_get_with_duplicate_content_length_closes_connection(monkeypatch # `Content-Length:\t` to the same '', so every blank spelling poisoned the socket # identically — as did a blank value duplicated, or paired with a real length. # -# A blank `Transfer-Encoding:` was the same hole one header over: '' sat in -# `_UNFRAMED_TRANSFER_CODINGS` beside `identity`, so a header naming no coding at -# all read as "no framing to worry about" and its payload poisoned the socket the -# same way (verified live: 403 then the same bad-syntax 400). Absence is -# something only the wire can say, and an absent header yields no values at all. +# A blank `Transfer-Encoding:` was the same hole one header over: '' reached the +# allowlist of codings that "framed nothing" (since deleted, along with the +# `identity` entry that was its last member), so a header naming no coding at all +# read as "no framing to worry about" and its payload poisoned the socket the same +# way (verified live: 403 then the same bad-syntax 400). Absence is something only +# the wire can say, and an absent header yields no values at all. _BLANK_FRAMING_HEADERS = [ @@ -390,10 +418,12 @@ def test_blank_transfer_encoding_is_reported_as_an_undecodable_coding(): assert coding is not None assert coding, "an empty return value would be a falsy non-None trap for callers" + # Only an ABSENT header frames nothing; a present one always names a coding + # this server cannot decode, `identity` included. assert unsupported_transfer_encoding(cast(Any, _message_with("Host: x\r\n"))) is None assert unsupported_transfer_encoding( cast(Any, _message_with("Host: x\r\nTransfer-Encoding: identity\r\n")) - ) is None + ) == "identity" @pytest.mark.parametrize("raw", _BLANK_FRAMING_HEADERS) @@ -427,14 +457,14 @@ def test_sidecar_get_with_blank_framing_value_closes_connection(raw, monkeypatch "Content-Length: 0\r\n", "Content-Length: 0 \r\n", "Content-Length: 0\r\nContent-Length: 0\r\n", - "Transfer-Encoding: identity\r\n", ], ) def test_blank_rule_does_not_over_close_a_bodyless_request(raw): """The other half of the contract: framing that says "no body" keeps keep-alive. - Two AGREEING zeroes, a padded zero and `identity` are honest statements that - nothing is queued, and none of them may be swept up by the blank rule. + No framing header at all, an OWS-padded zero and two AGREEING zeroes are + honest statements that nothing is queued, and none of them may be swept up by + the blank rule. """ from api.helpers import request_declares_body @@ -550,21 +580,69 @@ def test_absurdly_long_digit_run_is_unreadable_and_never_raises(): assert unreadable_content_length(cast(Any, handler)) is True -# ── The same audit one header over: spelling an undecodable coding ───────────── +# ── The same audit one header over: NO transfer coding is decodable here ─────── +# +# `identity` was exempt as a coding that "frames nothing", so `Transfer-Encoding: +# identity` plus payload bytes read as body-less: the sidecar rejection answered +# 403 with keep-alive and the payload became the next request line, and all four +# upload handlers fell through to a length-0 read. RFC 9112 6.3 is explicit -- +# a request whose final transfer coding is not `chunked` has a length the +# recipient cannot determine, so it must be refused and the connection closed -- +# and http.server decodes no coding at all, `chunked` included. Every spelling in +# the neighbourhood is swept here because three prior rounds each fixed one value +# of a framing rule and left an adjacent one poisoning the socket. +# +# The over-close half of this contract is pinned by the framing table above and by +# `test_blank_rule_does_not_over_close_a_bodyless_request` / +# `test_digit_grammar_does_not_over_close_an_honest_zero`: a request with NO +# framing header, or one whose declared lengths all agree on zero, keeps its +# keep-alive. An absent `Transfer-Encoding` is the only one that frames nothing. @pytest.mark.parametrize( "value", - ["\xa0identity", "identity\xa0", "\xa0identity\xa0", "\x85identity"], - ids=["nbsp-ahead", "nbsp-behind", "nbsp-both", "nel-ahead"], + [ + "identity", + "IDENTITY", + "Identity", + "identity ", + "\tidentity\t", + "\xa0identity", + "identity\xa0", + "\xa0identity\xa0", + "\x85identity", + "identity, chunked", + "chunked, identity", + "identity,identity", + ",", + "banana", + ], + ids=[ + "identity", + "upper", + "title", + "sp-padded", + "htab-padded", + "nbsp-ahead", + "nbsp-behind", + "nbsp-both", + "nel-ahead", + "identity-then-chunked", + "chunked-then-identity", + "identity-list", + "lone-comma", + "unknown-coding", + ], ) -def test_non_ascii_padded_identity_is_not_the_identity_coding(value): - """`str.strip()` erased U+00A0/U+0085, so `\\xa0identity` read back as `identity`. +def test_every_spelling_of_a_transfer_coding_declares_a_body(value): + """Case, OWS, non-OWS padding, comma lists, a bare comma, an unknown coding. - No token grammar allows the padding, so the value names no coding this server - can decode -- and it reached the wire: latin-1 request lines carry U+00A0 - fine. Live on the otherwise-fixed head, sidecar GET + payload: 403 with no - `Connection: close`, then `400 Bad request syntax ('{"stale": true}GET ...')`. + The ASCII `identity` spellings are the newly-closed hole (they normalized to + the exempt token); the U+00A0/U+0085-padded ones were the previous round's + (`str.strip()` erased the padding, so `\\xa0identity` read back as `identity`) + and they reach the wire because a request line decodes as latin-1. The comma + forms never matched the exempt token and already refused -- they are pinned so + a future normalizer that splits the list cannot reopen the hole. """ from api.helpers import request_declares_body, unsupported_transfer_encoding @@ -574,44 +652,23 @@ def test_non_ascii_padded_identity_is_not_the_identity_coding(value): assert request_declares_body(cast(Any, handler)) is True -@pytest.mark.parametrize( - "value", ["identity", "IDENTITY", "Identity", "identity ", "\tidentity\t"] -) -def test_ascii_case_and_ows_spellings_of_identity_stay_decodable(value): - """The over-close half of the coding audit: a real token still frames nothing.""" - from api.helpers import unsupported_transfer_encoding +@pytest.mark.parametrize("length", ["0", "15"], ids=["zero-length", "real-length"]) +def test_transfer_encoding_beside_a_content_length_still_declares_a_body(length): + """A coding plus a length is unframeable however honest the length looks. - handler = _message_with(f"Host: x\r\nTransfer-Encoding: {value}\r\n") - - assert unsupported_transfer_encoding(cast(Any, handler)) is None - - -def test_unframed_transfer_codings_cannot_be_spelled_non_ascii(): - """Pin WHY membership-by-`str.lower()` is safe, so a future coding can't break it. - - `str.lower()` maps some non-ASCII characters onto ASCII ones -- U+212A KELVIN - SIGN lowercases to `k`, so `'chunKed'.lower() == 'chunked'` -- which means a - coding in the unframed set containing such a letter would be reachable by a - spelling no HTTP token allows, and would read as "frames nothing" for a - request whose bytes are still queued. `identity` has no such letter. This - scans every codepoint rather than trusting the claim. + `Transfer-Encoding: identity` with `Content-Length: 0` was the worst of the + pair: both halves read as "no body", so the rejection kept the socket and the + payload behind them was parsed as the next request line. RFC 9112 6.3 refuses + the combination outright (it is the request-smuggling shape), and the coding + has to win -- a length cannot describe a body the server cannot decode. """ - from api.helpers import _UNFRAMED_TRANSFER_CODINGS + from api.helpers import request_declares_body - letters = set("".join(_UNFRAMED_TRANSFER_CODINGS)) - assert all( - coding.isascii() and coding.islower() for coding in _UNFRAMED_TRANSFER_CODINGS - ), _UNFRAMED_TRANSFER_CODINGS - foldable = [ - (hex(cp), chr(cp).lower()) - for cp in range(0x110000) - if not chr(cp).isascii() and chr(cp).lower() and set(chr(cp).lower()) <= letters - ] - assert not foldable, ( - f"a non-ASCII character now lowercases into {sorted(letters)}, so an " - f"unframed coding could be spelled outside the token grammar: {foldable}" + handler = _message_with( + f"Host: x\r\nTransfer-Encoding: identity\r\nContent-Length: {length}\r\n" ) - assert "chunKed".lower() == "chunked", "the fold this test guards against" + + assert request_declares_body(cast(Any, handler)) is True def test_csp_report_rate_limited_closes_connection(monkeypatch): @@ -808,37 +865,43 @@ def test_malformed_zero_content_length_multipart_rejects_before_read( @pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) -def test_non_ascii_padded_identity_multipart_rejects_before_read(handler_name, monkeypatch): - """`Transfer-Encoding: \\xa0identity` is not the `identity` token either. +@pytest.mark.parametrize( + "raw", + [ + "Transfer-Encoding: identity", + "Transfer-Encoding: IDENTITY ", + "Transfer-Encoding: \xa0identity", + "Transfer-Encoding:", + "Transfer-Encoding: identity\r\nContent-Length: 0", + "Transfer-Encoding: identity\r\nContent-Length: 88", + "Transfer-Encoding: banana", + ], + ids=[ + "identity", + "identity-cased-and-padded", + "nbsp-padded-identity", + "blank", + "identity-plus-zero-length", + "identity-plus-real-length", + "unknown-coding", + ], +) +def test_any_transfer_encoding_multipart_rejects_before_read(handler_name, raw, monkeypatch): + """No coding is decodable by an upload handler, so none may reach `rfile`. - `str.strip()` erased the U+00A0, so it read as unframed, fell through to the - length-0 read and answered 400 "No file field in request" with the payload - still queued (live: 400 then `400 Bad request syntax ('--x')`). + `identity` (any case, any OWS padding) was exempt as a coding that "frames + nothing", so each handler fell through to the length-0 read, answered 400 "No + file field in request" with keep-alive and left the whole multipart payload on + the socket (live: 400 then `400 Bad request syntax ('--x')`). The `\\xa0`-padded + spelling and the blank value are the previous round's holes, and the two + Content-Length pairings are the RFC 9112 6.3 smuggling shape -- the coding has + to win over any length beside it. `_NeverRead` proves the preflight answers + before a byte is consumed. """ from email.parser import Parser headers = Parser().parsestr( - "Content-Type: multipart/form-data; boundary=x\r\nTransfer-Encoding: \xa0identity\r\n\r\n" - ) - handler, answered = _run_multipart_handler(monkeypatch, handler_name, headers, _NeverRead()) - - assert answered["status"] == 411, answered - assert "Transfer-Encoding" in answered["payload"]["error"], answered - assert handler.close_connection is True - - -@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) -def test_blank_transfer_encoding_multipart_rejects_before_read(handler_name, monkeypatch): - """`Transfer-Encoding:` names no coding, so it cannot frame a body either. - - It used to read as "unframed" beside `identity` and fell through to the - length-0 read: 400 "No file field in request", keep-alive, and the payload - left on the socket (live: 400 then `400 Bad request syntax ('--x')`). - """ - from email.parser import Parser - - headers = Parser().parsestr( - "Content-Type: multipart/form-data; boundary=x\r\nTransfer-Encoding:\r\n\r\n" + "Content-Type: multipart/form-data; boundary=x\r\n" + raw + "\r\n\r\n" ) handler, answered = _run_multipart_handler(monkeypatch, handler_name, headers, _NeverRead()) diff --git a/tests/test_rejected_write_pooled_client.py b/tests/test_rejected_write_pooled_client.py index d343a863..229059f9 100644 --- a/tests/test_rejected_write_pooled_client.py +++ b/tests/test_rejected_write_pooled_client.py @@ -210,8 +210,9 @@ def test_duplicate_content_length_upload_closes_and_cannot_poison_the_socket(pat # sidecar GET -> 403, then 400 Bad request syntax ('{"stale": true}GET /...') # /api/upload -> 400, then 400 Bad request syntax ('--x') # `Message` collapses `Content-Length:` and `Content-Length: ` to the same '', -# and a blank `Transfer-Encoding:` sat in the unframed set beside `identity`, so -# each spelling below poisoned the socket the same way. +# and a blank `Transfer-Encoding:` reached the allowlist of codings that "framed +# nothing" (since deleted — see the identity section below), so each spelling +# below poisoned the socket the same way. _BLANK_FRAMING_HEADER_LINES = ( b"Content-Length:\r\n", @@ -319,6 +320,89 @@ def test_malformed_zero_framing_upload_closes_and_cannot_poison_the_socket(path, _assert_single_closed_response(answered, status, _MULTIPART_PAYLOAD) +# ── `Transfer-Encoding: identity` frames nothing a reader can trust ─────────── +# +# `identity` sat in an allowlist of codings that "frame nothing", so a request +# carrying it read back as body-less however many payload bytes followed. RFC +# 9112 §6.3 leaves no room for that: a request whose final transfer coding is not +# `chunked` cannot be framed by a recipient, so it must be refused and the +# connection closed — and `http.server` decodes no transfer coding at all, so +# `identity` is no more readable here than `chunked` is. Reproduced live on the +# otherwise-fixed head, pipelined down one socket: +# +# GET /api/extensions/probe/sidecar/ping `Transfer-Encoding: identity` +# {"stale": true} +# -> HTTP/1.1 403 Forbidden (no Connection: close) +# -> HTTP/1.1 400 Bad request syntax +# ('{"stale": true}GET /api/health/agent HTTP/1.1') +# POST /api/upload `Transfer-Encoding: identity` --x... +# -> HTTP/1.1 400 Bad Request (no Connection: close, "No file field") +# -> HTTP/1.1 400 Bad request syntax ('--x') +# +# Only the spellings that normalize to the bare token were poisoned — every +# neighbouring value (`identity, chunked`, `identity,identity`, a lone comma, an +# unknown coding, a non-OWS-padded token, a blank value) already missed the +# allowlist and already closed. Those rows are in the sweep below anyway: this is +# the fourth round in which an adjacent spelling of one framing rule survived a +# fix, so the whole neighbourhood gets pinned rather than just the reported case. + +_IDENTITY_FRAMING_LINES = [ + b"Transfer-Encoding: identity\r\n", + b"Transfer-Encoding: IDENTITY\r\n", + b"Transfer-Encoding: Identity\r\n", + b"Transfer-Encoding: identity \r\n", + b"Transfer-Encoding: \tidentity\t\r\n", + b"Transfer-Encoding: identity\r\nTransfer-Encoding: identity\r\n", + b"Transfer-Encoding: identity\r\nContent-Length: 0\r\n", + b"Transfer-Encoding: identity\r\nContent-Length: " + str(len(_BODY)).encode() + b"\r\n", + b"Transfer-Encoding: identity, chunked\r\n", + b"Transfer-Encoding: chunked, identity\r\n", + b"Transfer-Encoding: identity,identity\r\n", + b"Transfer-Encoding: ,\r\n", + b"Transfer-Encoding: banana\r\n", +] + + +@pytest.mark.parametrize("framing", _IDENTITY_FRAMING_LINES) +def test_identity_framing_sidecar_get_closes_and_cannot_poison_the_socket(framing): + answered = _pipelined_after( + b"GET /api/extensions/probe/sidecar/ping HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Content-Type: application/json\r\n" + framing + b"\r\n" + _BODY + ) + + _assert_single_closed_response(answered, b"403", _BODY) + + +@pytest.mark.parametrize("path", _MULTIPART_UPLOAD_PATHS) +@pytest.mark.parametrize( + "framing", + [ + b"Transfer-Encoding: identity\r\n", + b"Transfer-Encoding: IDENTITY \r\n", + b"Transfer-Encoding: identity\r\nContent-Length: 0\r\n", + ], + ids=["identity", "identity-cased-and-padded", "identity-plus-zero-length"], +) +def test_identity_framing_upload_closes_and_cannot_poison_the_socket(path, framing): + """All four multipart handlers, mirroring the chunked cases above. + + `identity` carries no Content-Length either, so the length-0 read found no + parts and each handler answered 400 "No file field in request" with the whole + multipart payload still queued on an open socket. + """ + answered = _pipelined_after( + f"POST {path} HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Content-Type: multipart/form-data; boundary=x\r\n".encode() + + framing + + b"\r\n" + + _MULTIPART_PAYLOAD + ) + + _assert_single_closed_response(answered, b"411", _MULTIPART_PAYLOAD) + + @pytest.mark.parametrize( "framing", [ @@ -327,8 +411,6 @@ def test_malformed_zero_framing_upload_closes_and_cannot_poison_the_socket(path, b"Content-Length: 00\r\n", b"Content-Length: 0 \r\n", b"Content-Length: 0\r\nContent-Length: 0\r\n", - b"Transfer-Encoding: identity\r\n", - b"Transfer-Encoding: IDENTITY \r\n", ], ids=[ "no-length", @@ -336,8 +418,6 @@ def test_malformed_zero_framing_upload_closes_and_cannot_poison_the_socket(path, "double-zero-length", "ows-padded-zero", "two-agreeing-zeroes", - "identity", - "identity-cased-and-padded", ], ) def test_bodyless_framing_keeps_the_pooled_socket_alive(framing): From 7862927ef51d8801cb9c5b9b3046ebac9da5d872 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Mon, 17 Aug 2026 02:10:45 -0300 Subject: [PATCH 46/47] fix(http): keep a bodyless rejection's keep-alive, at every reject site The mirror image of the sidecar over-close already fixed here: the sibling reject-before-read sites still armed `arm_connection_close()` unconditionally, so a request with NO declared body was answered with `Connection: close` and the client's pipelined follow-up was dropped -- a healthy pooled connection killed for no framing reason. Reproduced on the wire, pipelined down one socket against the production handler (`responses seen: 1` at each site): POST /api/session/new off-session auth -> 401 + Connection: close POST /api/session/new bad Origin -> 403 + Connection: close POST /api/session/new no CSRF token -> 403 + Connection: close POST /api/csp-report limiter tripped -> 204 + Connection: close POST /api/health/restart success -> 200 + Connection: close POST /api/process-complete-ack -> 410 + Connection: close POST /api/upload no boundary -> 400 + Connection: close Each now routes through `arm_connection_close_if_body_pending()` (the helper the sidecar path already used), so the framing decides in both directions: body-bearing or unreadable framing still closes, an absent or agreeing-zero Content-Length keeps keep-alive. `check_auth_or_close()` cannot swap the helper in directly -- it has to arm before `check_auth()` writes its 401/302 and undo that on success -- so it gates the arming on `request_declares_body()` instead and keeps the success-path restore. `/api/health/restart` is armed once, ahead of the outcome, so all four results (completed / in_progress / busy / error) follow the framing rather than the result; the success path was closing on every call the WebUI makes. Only the boundary check in the upload preflight can reject a bodyless request: every other raise there (any Transfer-Encoding, an unreadable or conflicting length, an over-cap length) already declares a body and still closes. Five tests pinned the unconditional close on stubs that declared no body -- i.e. they pinned the bug. Each is re-grounded on real body-bearing framing (Content-Length, chunked, and an unreadable length) so the close half stays pinned, with a bodyless sibling asserting keep-alive. New real-socket regressions cover all seven sites in both directions. --- api/auth.py | 17 +- api/routes.py | 45 ++- api/upload.py | 17 +- tests/test_rejected_write_connection_close.py | 275 +++++++++++++-- tests/test_rejected_write_pooled_client.py | 323 +++++++++++++++++- 5 files changed, 638 insertions(+), 39 deletions(-) diff --git a/api/auth.py b/api/auth.py index 87f6bc4f..c61ee669 100644 --- a/api/auth.py +++ b/api/auth.py @@ -17,6 +17,7 @@ import time from pathlib import Path from api.config import STATE_DIR, get_config, load_settings +from api.helpers import request_declares_body logger = logging.getLogger(__name__) @@ -1214,12 +1215,26 @@ def check_auth_or_close(handler, parsed) -> bool: can advertise ``Connection: close``; success restores the prior flag so an authenticated request keeps its keep-alive. + Armed ONLY when the request's framing declares a body still queued in + ``rfile`` (see ``request_declares_body()``), which is the same rule the other + reject-before-read sites apply through ``arm_connection_close_if_body_pending()``. + Arming unconditionally was the mirror image of the over-close this PR already + fixed on the sidecar path: a body-less POST that failed auth answered + ``401`` WITH ``Connection: close`` and dropped the client's pipelined + follow-up, killing a healthy keep-alive connection for no framing reason. + Verified on the wire, pipelined down one socket against the production + handler: ``POST /api/session/new`` with no ``Content-Length`` (and with + ``Content-Length: 0``) answered ``401`` + ``Connection: close`` and the + following ``GET /api/auth/status`` was never served. The helper cannot be + swapped in directly here because the arming has to happen before + ``check_auth()`` writes its response and be undone if it succeeds. + ``close_connection`` only exists once BaseHTTPRequestHandler has parsed a request line, so partially-built handler stubs may not have it at all. Read it defensively and only arm/restore when it was really there: inventing the attribute on a stub would leave a bogus keep-alive verdict behind. """ - if not hasattr(handler, 'close_connection'): + if not hasattr(handler, 'close_connection') or not request_declares_body(handler): return check_auth(handler, parsed) prior_close = handler.close_connection handler.close_connection = True diff --git a/api/routes.py b/api/routes.py index decc18eb..daca7c80 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2974,7 +2974,6 @@ from api.helpers import ( require, bad, safe_resolve, - arm_connection_close, arm_connection_close_if_body_pending, j, t, @@ -5873,9 +5872,15 @@ def _csrf_rejection_error(handler) -> str: def _check_csrf(handler) -> bool: """Reject cross-origin or tokenless authenticated browser unsafe requests.""" if not _check_same_origin_browser_request(handler): - # CSRF checks run before read_body(). Close rather than reusing an - # HTTP/1.1 connection whose unread body would corrupt the next request. - arm_connection_close(handler) + # CSRF checks run before read_body(), so close rather than reusing an + # HTTP/1.1 connection whose unread body would corrupt the next request -- + # but ONLY when the framing says bytes are really queued. Arming + # unconditionally dropped a healthy pooled connection on a body-less + # write: verified on the wire, `POST /api/session/new` with + # `Origin: http://evil.invalid` and no `Content-Length` (and with + # `Content-Length: 0`) answered 403 + `Connection: close` and the + # pipelined `GET /api/health/agent` was never served. + arm_connection_close_if_body_pending(handler) return False if not _is_browser_unsafe_request(handler): return True # non-browser clients (curl, MCP, agent) have no Origin/Referer @@ -5888,7 +5893,11 @@ def _check_csrf(handler) -> bool: submitted = handler.headers.get(CSRF_HEADER_NAME) or handler.headers.get("X-CSRF-Token") if verify_csrf_token(cookie_val or "", submitted or ""): return True - arm_connection_close(handler) + # Same framing rule as the origin rejection above: a token mismatch on a + # body-less write has nothing unread to protect. Verified on the wire with an + # authenticated same-origin `POST /api/session/new` carrying no CSRF token and + # no `Content-Length`: 403 + `Connection: close`, follow-up dropped. + arm_connection_close_if_body_pending(handler) return _set_csrf_failure_reason(handler, "token_mismatch") @@ -6519,8 +6528,12 @@ def _handle_csp_report(handler) -> bool: _client_ip_for_rate_limit(handler), ) # Rate-limit rejection runs before the body is read; close-and-advertise - # so the unread report can't corrupt the next pooled request. - arm_connection_close(handler) + # so the unread report can't corrupt the next pooled request -- but only + # when a body was really declared. A body-less report answered 204 WITH + # `Connection: close` once the 100-per-60s limiter tripped (reproduced on + # the wire at request 101; the pipelined `GET /api/health/agent` was + # dropped), so a browser that keeps reporting loses its socket each time. + arm_connection_close_if_body_pending(handler) return _send_no_content(handler) payload = _read_csp_report_payload(handler) @@ -12912,9 +12925,15 @@ def _handle_shutdown(handler) -> bool: def _handle_health_restart(handler) -> bool: """Restart the Hermes messaging gateway service.""" - # This endpoint never consumes its request body on any outcome; close so a - # supplied body can't corrupt the next pooled request. - arm_connection_close(handler) + # This endpoint never consumes its request body on any outcome, so close when + # one was DECLARED -- and only then. Arming unconditionally closed the socket + # on every call including the successful, body-less one the WebUI actually + # makes: verified on the wire, `POST /api/health/restart` with no + # `Content-Length` answered with `Connection: close` and the pipelined + # `GET /api/health/agent` was never served. The single arming covers every + # outcome below (completed / in_progress / busy / error) because the framing, + # not the result, decides. + arm_connection_close_if_body_pending(handler) outcome = restart_active_profile_gateway() if outcome.get("status") == "completed": @@ -15239,8 +15258,10 @@ def handle_post(handler, parsed) -> bool: try: # The 410 runs before the stale tab's JSON body is read; # close-and-advertise so those unread bytes can't corrupt the next - # pooled request. - arm_connection_close(handler) + # pooled request -- but a body-less ack has none, and closing then + # just kills keep-alive (on the wire: 410 + `Connection: close` with + # no `Content-Length`, pipelined follow-up dropped). + arm_connection_close_if_body_pending(handler) j( handler, { diff --git a/api/upload.py b/api/upload.py index 4174b389..3701e669 100644 --- a/api/upload.py +++ b/api/upload.py @@ -9,7 +9,7 @@ from pathlib import Path from api.config import MAX_UPLOAD_BYTES, STATE_DIR from api.helpers import ( - arm_connection_close, + arm_connection_close_if_body_pending, j, unreadable_content_length, unsupported_transfer_encoding, @@ -51,8 +51,19 @@ _MAX_EXTRACTED_BYTES = 10 * MAX_UPLOAD_BYTES def _reject_before_read(handler, message, status): - """Reject without reading the body; close so unread bytes can't poison HTTP/1.1 reuse.""" - arm_connection_close(handler) + """Reject without reading the body; close so unread bytes can't poison HTTP/1.1 reuse. + + Framing decides, exactly as at the other reject-before-read sites: every + rejection raised by the preflight above (any ``Transfer-Encoding``, an + unreadable or conflicting ``Content-Length``, an over-cap length) already + declares a body and still closes. Only the boundary/`parse_multipart` rejection + can fire on a request that declares none, and closing there dropped a healthy + pooled connection -- on the wire, ``POST /api/upload`` with + ``Content-Type: application/json`` and no ``Content-Length`` answered 400 + "No boundary in Content-Type" WITH ``Connection: close`` and the pipelined + ``GET /api/health/agent`` was never served. + """ + arm_connection_close_if_body_pending(handler) return j(handler, {'error': message}, status=status) diff --git a/tests/test_rejected_write_connection_close.py b/tests/test_rejected_write_connection_close.py index f5e80ce5..e45fd46a 100644 --- a/tests/test_rejected_write_connection_close.py +++ b/tests/test_rejected_write_connection_close.py @@ -11,13 +11,15 @@ from typing import Any, cast import pytest -def test_auth_rejected_write_closes_connection(monkeypatch): +def _rejected_write(monkeypatch, headers): + """Drive the production _handle_write through an auth rejection.""" import api.auth as auth import server handler = SimpleNamespace( path="/api/session/new", command="POST", + headers=headers, close_connection=False, ) monkeypatch.setattr(server, "reset_trusted_auth_request_state", lambda _handler: None) @@ -29,25 +31,65 @@ def test_auth_rejected_write_closes_connection(monkeypatch): raise AssertionError("route ran after auth rejection") server.Handler._handle_write(cast(Any, handler), route_must_not_run) - - assert handler.close_connection is True + return handler -def test_origin_rejected_csrf_closes_connection(monkeypatch): +@pytest.mark.parametrize( + "headers", + [{"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}, {"Content-Length": "banana"}], + ids=["content-length", "chunked", "unreadable-length"], +) +def test_auth_rejected_write_closes_connection(headers, monkeypatch): + """A rejected write whose framing declares a body leaves it unread — close.""" + assert _rejected_write(monkeypatch, headers).close_connection is True + + +@pytest.mark.parametrize( + "headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"] +) +def test_bodyless_auth_rejected_write_keeps_connection(headers, monkeypatch): + """The over-close half at the auth gate: no declared body, nothing to protect. + + `check_auth_or_close()` armed unconditionally, so a body-less POST that failed + auth answered 401 WITH `Connection: close` and dropped the client's pipelined + follow-up (reproduced on the wire — see the pooled-client file). Same rule as + the sidecar path: the framing decides, not the method or the site. + """ + assert _rejected_write(monkeypatch, headers).close_connection is False + + +def _csrf_origin_rejection(monkeypatch, headers): import api.routes as routes - handler = SimpleNamespace(close_connection=False) + handler = SimpleNamespace(headers=headers, close_connection=False) monkeypatch.setattr(routes, "_check_same_origin_browser_request", lambda _handler: False) assert routes._check_csrf(handler) is False - assert handler.close_connection is True + return handler -def test_token_rejected_csrf_closes_connection(monkeypatch): +@pytest.mark.parametrize( + "headers", + [{"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}, {"Content-Length": "banana"}], + ids=["content-length", "chunked", "unreadable-length"], +) +def test_origin_rejected_csrf_closes_connection(headers, monkeypatch): + assert _csrf_origin_rejection(monkeypatch, headers).close_connection is True + + +@pytest.mark.parametrize( + "headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"] +) +def test_bodyless_origin_rejected_csrf_keeps_connection(headers, monkeypatch): + """A cross-origin rejection of a body-less write must not drop the socket.""" + assert _csrf_origin_rejection(monkeypatch, headers).close_connection is False + + +def _csrf_token_rejection(monkeypatch, headers): import api.auth as auth import api.routes as routes - handler = SimpleNamespace(headers={}, close_connection=False) + handler = SimpleNamespace(headers=headers, close_connection=False) monkeypatch.setattr(routes, "_check_same_origin_browser_request", lambda _handler: True) monkeypatch.setattr(routes, "_is_browser_unsafe_request", lambda _handler: True) monkeypatch.setattr(auth, "is_auth_enabled", lambda: True) @@ -55,7 +97,24 @@ def test_token_rejected_csrf_closes_connection(monkeypatch): monkeypatch.setattr(auth, "verify_csrf_token", lambda _cookie, _token: False) assert routes._check_csrf(handler) is False - assert handler.close_connection is True + return handler + + +@pytest.mark.parametrize( + "headers", + [{"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}, {"Content-Length": "banana"}], + ids=["content-length", "chunked", "unreadable-length"], +) +def test_token_rejected_csrf_closes_connection(headers, monkeypatch): + assert _csrf_token_rejection(monkeypatch, headers).close_connection is True + + +@pytest.mark.parametrize( + "headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"] +) +def test_bodyless_token_rejected_csrf_keeps_connection(headers, monkeypatch): + """A token mismatch on a body-less write has nothing unread either.""" + assert _csrf_token_rejection(monkeypatch, headers).close_connection is False def test_sidecar_provenance_rejected_closes_connection(monkeypatch): @@ -671,30 +730,120 @@ def test_transfer_encoding_beside_a_content_length_still_declares_a_body(length) assert request_declares_body(cast(Any, handler)) is True -def test_csp_report_rate_limited_closes_connection(monkeypatch): - """A rate-limited CSP report is dropped before its body is read.""" +def _rate_limited_csp_report(monkeypatch, headers): import api.routes as routes - handler = SimpleNamespace(close_connection=False) + handler = SimpleNamespace(headers=headers, close_connection=False) monkeypatch.setattr(routes, "_csp_report_rate_limited", lambda _handler: True) monkeypatch.setattr(routes, "_send_no_content", lambda _handler: True) assert routes._handle_csp_report(handler) is True - assert handler.close_connection is True + return handler -def test_health_restart_closes_connection(monkeypatch): - """health/restart never consumes its body on any outcome.""" +@pytest.mark.parametrize( + "headers", + [{"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}, {"Content-Length": "banana"}], + ids=["content-length", "chunked", "unreadable-length"], +) +def test_csp_report_rate_limited_closes_connection(headers, monkeypatch): + """A rate-limited CSP report is dropped before its body is read.""" + assert _rate_limited_csp_report(monkeypatch, headers).close_connection is True + + +@pytest.mark.parametrize( + "headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"] +) +def test_bodyless_rate_limited_csp_report_keeps_connection(headers, monkeypatch): + """A body-less report dropped by the limiter must not cost the browser its socket.""" + assert _rate_limited_csp_report(monkeypatch, headers).close_connection is False + + +_RESTART_OUTCOMES = [ + {"status": "completed"}, + {"status": "in_progress"}, + {"status": "busy"}, + {"status": "error"}, +] + + +@pytest.mark.parametrize("outcome", _RESTART_OUTCOMES, ids=lambda o: o["status"]) +@pytest.mark.parametrize( + "headers", + [{"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}, {"Content-Length": "banana"}], + ids=["content-length", "chunked", "unreadable-length"], +) +def test_health_restart_closes_connection(headers, outcome, monkeypatch): + """health/restart never consumes its body on any outcome — so a declared one closes.""" import api.routes as routes - handler = SimpleNamespace(close_connection=False) - monkeypatch.setattr(routes, "restart_active_profile_gateway", lambda: {"status": "completed"}) + handler = SimpleNamespace(headers=headers, close_connection=False) + monkeypatch.setattr(routes, "restart_active_profile_gateway", lambda: outcome) monkeypatch.setattr(routes, "j", lambda _handler, _payload, status=200: True) routes._handle_health_restart(handler) assert handler.close_connection is True +@pytest.mark.parametrize("outcome", _RESTART_OUTCOMES, ids=lambda o: o["status"]) +@pytest.mark.parametrize( + "headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"] +) +def test_bodyless_health_restart_keeps_connection(headers, outcome, monkeypatch): + """Including the SUCCESS path, which closed a healthy socket on every call. + + The WebUI's restart button sends no body, so the old unconditional arming made + a 200 "restarted successfully" hang up the connection every single time. The + framing decides for all four outcomes, not the result. + """ + import api.routes as routes + + handler = SimpleNamespace(headers=headers, close_connection=False) + monkeypatch.setattr(routes, "restart_active_profile_gateway", lambda: outcome) + monkeypatch.setattr(routes, "j", lambda _handler, _payload, status=200: True) + + routes._handle_health_restart(handler) + assert handler.close_connection is False + + +def _deprecated_ack_post(monkeypatch, headers): + """Drive the deprecated /api/process-complete-ack 410, which runs pre-CSRF.""" + import api.routes as routes + + handler = SimpleNamespace( + path="/api/process-complete-ack", + command="POST", + headers=headers, + close_connection=False, + ) + monkeypatch.setattr( + routes, "j", lambda _handler, _payload, status=200, extra_headers=None: True + ) + + assert routes.handle_post( + cast(Any, handler), SimpleNamespace(path=handler.path, query="") + ) is True + return handler + + +@pytest.mark.parametrize( + "headers", + [{"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}, {"Content-Length": "banana"}], + ids=["content-length", "chunked", "unreadable-length"], +) +def test_deprecated_ack_with_a_body_closes_connection(headers, monkeypatch): + """A stale tab's JSON ack is never read, so a declared body must close.""" + assert _deprecated_ack_post(monkeypatch, headers).close_connection is True + + +@pytest.mark.parametrize( + "headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"] +) +def test_bodyless_deprecated_ack_keeps_connection(headers, monkeypatch): + """A body-less ack has nothing queued — the 410 must not kill keep-alive.""" + assert _deprecated_ack_post(monkeypatch, headers).close_connection is False + + def test_upload_oversize_rejects_before_read(monkeypatch): """An oversized upload's 413 fires before rfile.read() — connection closes.""" import api.upload as upload @@ -779,6 +928,52 @@ def test_bodyless_multipart_rejection_keeps_connection(handler_name, monkeypatch assert handler.close_connection is False +@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) +@pytest.mark.parametrize( + "headers", + [ + {"Content-Type": "application/json"}, + {"Content-Type": "application/json", "Content-Length": "0"}, + {"Content-Type": "multipart/form-data"}, # multipart, but no boundary parameter + ], + ids=["json-no-length", "json-zero-length", "multipart-without-boundary"], +) +def test_bodyless_boundary_rejection_keeps_connection(handler_name, headers, monkeypatch): + """`_reject_before_read()` fires on a body-less request too — via the boundary check. + + Every other rejection the preflight raises (any Transfer-Encoding, an unreadable + or conflicting length, an over-cap length) already declares a body. The missing + `boundary=` in Content-Type is the one that can fire with no body at all, and + the unconditional arming closed a healthy pooled socket for it: on the wire, + `POST /api/upload` with `Content-Type: application/json` and no `Content-Length` + answered 400 "No boundary in Content-Type" WITH `Connection: close` and the + pipelined GET was dropped. `_NeverRead` also proves the boundary check still + runs ahead of any `rfile.read()`. + """ + handler, answered = _run_multipart_handler( + monkeypatch, handler_name, headers, _NeverRead() + ) + + assert answered["status"] == 400, answered + assert answered["payload"]["error"] == "No boundary in Content-Type", answered + assert handler.close_connection is False + + +@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) +def test_boundary_rejection_with_a_declared_body_still_closes(handler_name, monkeypatch): + """The close half of the same rejection: a declared body is still queued.""" + handler, answered = _run_multipart_handler( + monkeypatch, + handler_name, + {"Content-Type": "application/json", "Content-Length": "15"}, + _NeverRead(), + ) + + assert answered["status"] == 400, answered + assert answered["payload"]["error"] == "No boundary in Content-Type", answered + assert handler.close_connection is True + + @pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS) def test_non_numeric_content_length_still_closes(handler_name, monkeypatch): """The pre-existing invalid-Content-Length reject keeps its 400 and its close.""" @@ -1038,17 +1233,57 @@ def test_check_auth_or_close_on_handler_stub_without_close_connection(monkeypatc assert not hasattr(handler, "close_connection") -def test_check_auth_or_close_restores_keep_alive_on_success(monkeypatch): - """A successful auth must not inherit the armed close.""" +@pytest.mark.parametrize( + "headers", + [{}, {"Content-Length": "0"}, {"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}], + ids=["no-content-length", "zero-content-length", "content-length", "chunked"], +) +def test_check_auth_or_close_restores_keep_alive_on_success(headers, monkeypatch): + """A successful auth must not inherit the armed close. + + The two body-bearing rows are the ones that actually arm before `check_auth()`, + so they are what pins the restore; the body-less rows never arm at all. + """ import api.auth as auth - handler = SimpleNamespace(close_connection=False) + handler = SimpleNamespace(headers=headers, close_connection=False) monkeypatch.setattr(auth, "check_auth", lambda _handler, _parsed: True) assert auth.check_auth_or_close(cast(Any, handler), SimpleNamespace(path="/api/x")) is True assert handler.close_connection is False +def test_check_auth_or_close_restores_a_prior_close_on_success(monkeypatch): + """Restore means restore: a connection already doomed stays doomed. + + `Connection: close` sent by the client sets `close_connection` before routing, + and the restore must not overwrite that with keep-alive. + """ + import api.auth as auth + + handler = SimpleNamespace(headers={"Content-Length": "15"}, close_connection=True) + monkeypatch.setattr(auth, "check_auth", lambda _handler, _parsed: True) + + assert auth.check_auth_or_close(cast(Any, handler), SimpleNamespace(path="/api/x")) is True + assert handler.close_connection is True + + +def test_check_auth_or_close_tolerates_a_handler_without_headers(monkeypatch): + """The framing read must never raise here: an exception becomes a spurious 500.""" + import api.auth as auth + + monkeypatch.setattr(auth, "check_auth", lambda _handler, _parsed: False) + + for handler in ( + SimpleNamespace(close_connection=False), + SimpleNamespace(headers=None, close_connection=False), + SimpleNamespace(headers=object(), close_connection=False), + ): + assert auth.check_auth_or_close(cast(Any, handler), SimpleNamespace(path="/api/x")) is False + # No framing could be read, so nothing proves a body is pending. + assert handler.close_connection is False + + def test_arm_connection_close_tolerates_a_read_only_handler(): """Arming must never turn a 4xx into a 500, even on an odd handler.""" from api.helpers import arm_connection_close diff --git a/tests/test_rejected_write_pooled_client.py b/tests/test_rejected_write_pooled_client.py index 229059f9..6cbcbed1 100644 --- a/tests/test_rejected_write_pooled_client.py +++ b/tests/test_rejected_write_pooled_client.py @@ -18,9 +18,12 @@ before reading its JSON body, by design (it runs ahead of the CSRF gate so a stale tab gets 410, not 403). """ +import contextlib import http.client import socket +import threading import urllib.parse +from http.server import ThreadingHTTPServer import pytest @@ -91,17 +94,26 @@ _CHUNKED_MULTIPART_BODY = ( ) -def _pipelined_after(request: bytes, *, stop_after: int | None = None) -> bytes: +def _pipelined_after( + request: bytes, + *, + stop_after: int | None = None, + port: int | None = None, + follow: bytes = _FOLLOWING_GET, +) -> bytes: """Send *request* and a plain GET in one write; return every byte answered. Reads to EOF by default, which is the whole point of the closing cases: the proof is that NOTHING follows the single response. `stop_after` bounds the read for the keep-alive cases, where the socket stays open by design and reading to EOF would only burn the timeout. + + `port`/`follow` exist for the auth-enabled cases below, which need their own + server instance and a PUBLIC follow-up path. """ - sock = socket.create_connection(("127.0.0.1", _PORT), timeout=10) + sock = socket.create_connection(("127.0.0.1", port or _PORT), timeout=10) try: - sock.sendall(request + _FOLLOWING_GET) + sock.sendall(request + follow) received = b"" while stop_after is None or received.count(b"HTTP/1.1 ") < stop_after: try: @@ -440,3 +452,308 @@ def test_bodyless_framing_keeps_the_pooled_socket_alive(framing): f"healthy pooled connection: {text}" ) assert b"Connection: close" not in answered, text + + +# ── Every reject-before-read site, in BOTH directions, on the wire ───────────── +# +# The sidecar path above was the first site routed through the framing-aware +# helper; the sibling sites kept the unconditional `arm_connection_close()` and +# so were the mirror image of the same bug -- a body-less rejection answered with +# `Connection: close` and dropped the client's pipelined follow-up, killing a +# healthy keep-alive connection for no framing reason. Each pair below is the +# reproduction (body-less -> must stay alive) beside its close half (a declared +# body -> must still close), pipelined down one socket against the production +# handler. Reproduced on the pre-fix head, all seven sites, `responses seen: 1`: +# +# POST /api/session/new (auth off-session) -> 401 + Connection: close +# POST /api/session/new (bad Origin) -> 403 + Connection: close +# POST /api/session/new (no CSRF token) -> 403 + Connection: close +# POST /api/csp-report (limiter tripped) -> 204 + Connection: close +# POST /api/health/restart (success) -> 200 + Connection: close +# POST /api/process-complete-ack -> 410 + Connection: close +# POST /api/upload (no boundary) -> 400 + Connection: close + +_BODYLESS_FRAMING = [b"", b"Content-Length: 0\r\n"] +_BODYLESS_IDS = ["no-content-length", "zero-content-length"] +# A PUBLIC path, so the follow-up is a genuine 200 even with auth enabled. +_PUBLIC_FOLLOWING_GET = ( + b"GET /api/auth/status HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept: */*\r\n\r\n" +) + + +@contextlib.contextmanager +def _own_server(): + """The production Handler on a private port. + + The shared test server runs with auth disabled and out of process, so the + auth/CSRF-token rejections and the deterministic restart/limiter outcomes need + an instance this process can configure. It is the same `server.Handler` class + the real server binds, driven over a real socket. + """ + import server + + httpd = ThreadingHTTPServer(("127.0.0.1", 0), server.Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield httpd.server_address[1] + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=10) + + +@pytest.fixture +def auth_on(monkeypatch): + """Turn password auth on for this test (conftest resets the hash cache).""" + import api.auth as auth + + monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "pooled-client-regression") + auth._invalidate_password_hash_cache() + assert auth.is_auth_enabled(), "the auth-path regression needs auth enabled" + return auth + + +def _assert_kept_alive(answered: bytes, status: bytes) -> None: + text = answered.decode("latin-1", errors="replace") + assert answered.startswith(b"HTTP/1.1 " + status), text + assert b"Connection: close" not in answered, ( + f"a body-less rejection advertised close, so a pooled client's healthy " + f"connection was dropped: {text}" + ) + assert b"HTTP/1.1 200 OK" in answered, ( + f"the pipelined follow-up was not served on the same socket: {text}" + ) + + +def _assert_closed(answered: bytes, status: bytes) -> None: + text = answered.decode("latin-1", errors="replace") + assert answered.startswith(b"HTTP/1.1 " + status), text + assert b"Connection: close" in answered, text + assert answered.count(b"HTTP/1.1 ") == 1, ( + f"the pipelined GET was answered, so the socket stayed open: {text}" + ) + + +@pytest.mark.parametrize("framing", _BODYLESS_FRAMING, ids=_BODYLESS_IDS) +def test_bodyless_auth_rejection_keeps_the_pooled_socket_alive(framing, auth_on): + """A body-less POST that fails auth must not cost the client its connection.""" + with _own_server() as port: + answered = _pipelined_after( + b"POST /api/session/new HTTP/1.1\r\nHost: 127.0.0.1\r\n" + framing + b"\r\n", + stop_after=2, + port=port, + follow=_PUBLIC_FOLLOWING_GET, + ) + + _assert_kept_alive(answered, b"401") + + +def test_auth_rejection_with_a_body_still_closes_the_pooled_socket(auth_on): + """The close half at the auth gate: those bytes are still queued in rfile.""" + with _own_server() as port: + answered = _pipelined_after( + b"POST /api/session/new HTTP/1.1\r\nHost: 127.0.0.1\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: " + str(len(_BODY)).encode() + b"\r\n\r\n" + _BODY, + port=port, + follow=_PUBLIC_FOLLOWING_GET, + ) + + _assert_closed(answered, b"401") + assert _BODY not in answered, answered.decode("latin-1", errors="replace") + + +@pytest.mark.parametrize("framing", _BODYLESS_FRAMING, ids=_BODYLESS_IDS) +def test_bodyless_csrf_origin_rejection_keeps_the_pooled_socket_alive(framing): + """`_check_csrf()` origin mismatch, on the shared server (auth off).""" + answered = _pipelined_after( + b"POST /api/session/new HTTP/1.1\r\nHost: 127.0.0.1\r\n" + b"Origin: http://evil.invalid\r\n" + framing + b"\r\n", + stop_after=2, + ) + + _assert_kept_alive(answered, b"403") + + +def test_csrf_origin_rejection_with_a_body_still_closes_the_pooled_socket(): + answered = _pipelined_after( + b"POST /api/session/new HTTP/1.1\r\nHost: 127.0.0.1\r\n" + b"Origin: http://evil.invalid\r\nContent-Type: application/json\r\n" + b"Content-Length: " + str(len(_BODY)).encode() + b"\r\n\r\n" + _BODY + ) + + _assert_closed(answered, b"403") + assert _BODY not in answered, answered.decode("latin-1", errors="replace") + + +def _authenticated_same_origin_post(port, cookie_name, cookie, framing, body=b""): + return ( + f"POST /api/session/new HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n" + f"Origin: http://127.0.0.1:{port}\r\n" + f"Cookie: {cookie_name}={cookie}\r\n".encode() + framing + b"\r\n" + body + ) + + +@pytest.mark.parametrize("framing", _BODYLESS_FRAMING, ids=_BODYLESS_IDS) +def test_bodyless_csrf_token_rejection_keeps_the_pooled_socket_alive(framing, auth_on): + """A real session, a same-origin POST, and no CSRF token -> token_mismatch.""" + cookie = auth_on.create_session() + try: + with _own_server() as port: + answered = _pipelined_after( + _authenticated_same_origin_post( + port, auth_on._resolve_cookie_name(), cookie, framing + ), + stop_after=2, + port=port, + follow=_PUBLIC_FOLLOWING_GET, + ) + finally: + auth_on.invalidate_session(cookie) + + _assert_kept_alive(answered, b"403") + + +def test_csrf_token_rejection_with_a_body_still_closes_the_pooled_socket(auth_on): + cookie = auth_on.create_session() + try: + with _own_server() as port: + answered = _pipelined_after( + _authenticated_same_origin_post( + port, + auth_on._resolve_cookie_name(), + cookie, + b"Content-Type: application/json\r\nContent-Length: " + + str(len(_BODY)).encode() + + b"\r\n", + body=_BODY, + ), + port=port, + follow=_PUBLIC_FOLLOWING_GET, + ) + finally: + auth_on.invalidate_session(cookie) + + _assert_closed(answered, b"403") + assert _BODY not in answered, answered.decode("latin-1", errors="replace") + + +@pytest.fixture +def csp_limiter_tripped(monkeypatch): + """Force the CSP-report limiter, instead of sending 101 reports in 60s.""" + import api.routes as routes + + monkeypatch.setattr(routes, "_csp_report_rate_limited", lambda _handler: True) + + +@pytest.mark.parametrize("framing", _BODYLESS_FRAMING, ids=_BODYLESS_IDS) +def test_bodyless_rate_limited_csp_report_keeps_the_pooled_socket_alive( + framing, csp_limiter_tripped +): + """A dropped 204 must not hang up on a browser that is still reporting.""" + with _own_server() as port: + answered = _pipelined_after( + b"POST /api/csp-report HTTP/1.1\r\nHost: 127.0.0.1\r\n" + framing + b"\r\n", + stop_after=2, + port=port, + follow=_PUBLIC_FOLLOWING_GET, + ) + + _assert_kept_alive(answered, b"204") + + +def test_rate_limited_csp_report_with_a_body_still_closes(csp_limiter_tripped): + """The limiter answers without reading the report, so a declared body closes.""" + with _own_server() as port: + answered = _pipelined_after( + b"POST /api/csp-report HTTP/1.1\r\nHost: 127.0.0.1\r\n" + b"Content-Type: application/csp-report\r\n" + b"Content-Length: " + str(len(_BODY)).encode() + b"\r\n\r\n" + _BODY, + port=port, + follow=_PUBLIC_FOLLOWING_GET, + ) + + _assert_closed(answered, b"204") + + +@pytest.fixture +def restart_succeeds(monkeypatch): + """Pin the SUCCESS outcome -- the one that closed a healthy socket every call.""" + import api.routes as routes + + monkeypatch.setattr( + routes, "restart_active_profile_gateway", lambda: {"status": "completed"} + ) + + +@pytest.mark.parametrize("framing", _BODYLESS_FRAMING, ids=_BODYLESS_IDS) +def test_bodyless_successful_restart_keeps_the_pooled_socket_alive(framing, restart_succeeds): + """`/api/health/restart` closed on success too; the WebUI sends no body.""" + with _own_server() as port: + answered = _pipelined_after( + b"POST /api/health/restart HTTP/1.1\r\nHost: 127.0.0.1\r\n" + framing + b"\r\n", + stop_after=2, + port=port, + follow=_PUBLIC_FOLLOWING_GET, + ) + + _assert_kept_alive(answered, b"200") + + +def test_restart_with_a_body_still_closes_the_pooled_socket(restart_succeeds): + """The endpoint consumes its body on no outcome, so a declared one closes.""" + with _own_server() as port: + answered = _pipelined_after( + b"POST /api/health/restart HTTP/1.1\r\nHost: 127.0.0.1\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: " + str(len(_BODY)).encode() + b"\r\n\r\n" + _BODY, + port=port, + follow=_PUBLIC_FOLLOWING_GET, + ) + + _assert_closed(answered, b"200") + assert _BODY not in answered, answered.decode("latin-1", errors="replace") + + +@pytest.mark.parametrize("framing", _BODYLESS_FRAMING, ids=_BODYLESS_IDS) +def test_bodyless_deprecated_ack_keeps_the_pooled_socket_alive(framing): + """The 410 alias, which the body-bearing test at the top of this file pins closed.""" + answered = _pipelined_after( + b"POST /api/process-complete-ack HTTP/1.1\r\nHost: 127.0.0.1\r\n" + framing + b"\r\n", + stop_after=2, + ) + + _assert_kept_alive(answered, b"410") + + +@pytest.mark.parametrize("path", _MULTIPART_UPLOAD_PATHS) +@pytest.mark.parametrize( + "framing", + [ + b"Content-Type: application/json\r\n", + b"Content-Type: application/json\r\nContent-Length: 0\r\n", + b"Content-Type: multipart/form-data\r\n", # multipart, but no boundary= + ], + ids=["json-no-length", "json-zero-length", "multipart-without-boundary"], +) +def test_bodyless_upload_boundary_rejection_keeps_the_pooled_socket_alive(path, framing): + """`_reject_before_read()`'s one body-less reachable rejection: the boundary check.""" + answered = _pipelined_after( + f"POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n".encode() + framing + b"\r\n", + stop_after=2, + ) + + _assert_kept_alive(answered, b"400") + + +@pytest.mark.parametrize("path", _MULTIPART_UPLOAD_PATHS) +def test_upload_boundary_rejection_with_a_body_still_closes(path): + answered = _pipelined_after( + f"POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n" + "Content-Type: application/json\r\n" + f"Content-Length: {len(_BODY)}\r\n\r\n".encode() + _BODY + ) + + _assert_closed(answered, b"400") + assert _BODY not in answered, answered.decode("latin-1", errors="replace") From 5982a56698a0b7c5203a92a23e67375168c01abb Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 22 Sep 2026 12:53:29 +0000 Subject: [PATCH 47/47] fix(server): close the do_GET auth residual + recover comma-combined Content-Length Gate fixes on top of @rodrigogs's #6658, both independently reproduced first. do_GET called bare check_auth while every write path used check_auth_or_close, so a body-bearing GET failing top-level auth left its bytes queued on a keep-alive socket. check_auth_or_close is already body-pending-aware, so the bodyless login-redirect flow keeps its keep-alive. Removing the now-unused check_auth import (ruff F401) broke 9 tests that monkeypatch that symbol through a module alias; rewritten to the new name. _declared_content_lengths ran isdigit() across the whole raw header value, so a comma-combined 'Content-Length: 0, 0' -- the same bodyless request as two repeated headers under RFC 9110 5.3 -- read as unreadable and over-closed a healthy connection. Now splits on commas before validating each member; disagreeing lists still fail closed. Verified: 16/16 adversarial CL vectors still fail closed (no fail-open reopened), 13/13 over-close cases fixed, live-socket drive shows close armed on a body-bearing reject and keep-alive preserved on a bodyless one, and a chunked request behaves byte-identically to master (master never supported chunked either, so no proxy deployment regresses). Co-authored-by: rodrigogs --- CHANGELOG.md | 12 ++ api/helpers.py | 21 ++- server.py | 5 +- tests/test_broken_pipe_cascade.py | 48 +++--- tests/test_comma_combined_content_length.py | 105 ++++++++++++ tests/test_get_with_body_auth_reject_reuse.py | 158 ++++++++++++++++++ tests/test_issue1909_csp_report_only.py | 2 +- tests/test_production_do_get_body_pending.py | 127 ++++++++++++++ 8 files changed, 444 insertions(+), 34 deletions(-) create mode 100644 tests/test_comma_combined_content_length.py create mode 100644 tests/test_get_with_body_auth_reject_reuse.py create mode 100644 tests/test_production_do_get_body_pending.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b97d737..60f5ee11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ ### Fixed +- **A rejected request no longer poisons the next one on the same connection.** `server.py` + is a raw HTTP/1.1 handler where `rfile` is the socket itself, so answering a request + before reading its body left those bytes queued. The next request on a keep-alive + connection was then parsed starting mid-body, and the client got + `400 Bad request syntax ('{"a": "b"}GET /api/health HTTP/1.1')` — an error naming a + request it never sent, which is expensive to diagnose from the client side. Every + reject path now arms `Connection: close` when a body is still pending, and framing is + validated strictly (RFC 9110 `1*DIGIT`, duplicate and comma-combined `Content-Length` + reconciled, every `Transfer-Encoding` refused since nothing here decodes one). A + genuinely bodyless rejection keeps its keep-alive, so healthy pooled connections are + not dropped. Thanks @rodrigogs. (#7550, #6658) + - **Opening a session that belongs to another profile now offers to switch to it instead of looking deleted.** Several cross-profile guards answered `404 Session not found`, which the front-end treats as a missing session and self-heals by clearing the URL and local storage — diff --git a/api/helpers.py b/api/helpers.py index 9de52ba5..1984bf28 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -306,13 +306,20 @@ def _declared_content_lengths(handler) -> set[int] | None: """ parsed: set[int] = set() for raw in _framing_header_values(handler, 'Content-Length'): - stripped = raw.strip(_FIELD_VALUE_OWS) - if not (stripped.isascii() and stripped.isdigit()): - return None - try: - parsed.add(int(stripped)) - except ValueError: - return None + # RFC 9110 §5.3: a list-valued field may arrive as repeated lines OR as + # one comma-combined line, and the two spellings mean the same thing. + # So "0, 0" is the same bodyless request as two "Content-Length: 0" + # headers and must keep its keep-alive; only a DISAGREEING list (caught + # below by len(parsed) > 1) is unframeable. Without the split the comma + # fails isdigit() and every agreeing list over-closed a healthy socket. + for member in raw.split(','): + stripped = member.strip(_FIELD_VALUE_OWS) + if not (stripped.isascii() and stripped.isdigit()): + return None + try: + parsed.add(int(stripped)) + except ValueError: + return None return parsed diff --git a/server.py b/server.py index 39af7793..06fc9a21 100644 --- a/server.py +++ b/server.py @@ -100,7 +100,7 @@ from urllib.parse import urlparse logger = logging.getLogger(__name__) from api.request_logging import emit_request_log -from api.auth import check_auth, check_auth_or_close, reset_trusted_auth_request_state +from api.auth import check_auth_or_close, reset_trusted_auth_request_state from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE from api.helpers import ( j, @@ -378,7 +378,8 @@ class Handler(BaseHTTPRequestHandler): set_request_profile(cookie_profile) try: parsed = urlparse(self.path) - if not check_auth(self, parsed): return + # Body-pending-aware: a body-bearing GET failing auth would poison reuse (#7550). + if not check_auth_or_close(self, parsed): return result = handle_get(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) diff --git a/tests/test_broken_pipe_cascade.py b/tests/test_broken_pipe_cascade.py index c981d3eb..9e612670 100644 --- a/tests/test_broken_pipe_cascade.py +++ b/tests/test_broken_pipe_cascade.py @@ -227,14 +227,14 @@ class TestServerDisconnectHandling(unittest.TestCase): # Patch handle_get to raise BrokenPipeError import server as _server_mod orig_handle_get = _server_mod.handle_get - orig_check_auth = _server_mod.check_auth + orig_check_auth = _server_mod.check_auth_or_close _server_mod.handle_get = _fake_handle_get - _server_mod.check_auth = lambda h, p: True + _server_mod.check_auth_or_close = lambda h, p: True try: Handler.do_GET(handler) finally: _server_mod.handle_get = orig_handle_get - _server_mod.check_auth = orig_check_auth + _server_mod.check_auth_or_close = orig_check_auth # send_response should NEVER be called for the 500 — client is gone handler.send_response.assert_not_called() @@ -248,12 +248,12 @@ class TestServerDisconnectHandling(unittest.TestCase): raise self._route_raises import server as _server_mod - orig_check_auth = _server_mod.check_auth - _server_mod.check_auth = lambda h, p: True + orig_check_auth = _server_mod.check_auth_or_close + _server_mod.check_auth_or_close = lambda h, p: True try: Handler._handle_write(handler, _fake_route) finally: - _server_mod.check_auth = orig_check_auth + _server_mod.check_auth_or_close = orig_check_auth handler.send_response.assert_not_called() @@ -286,9 +286,9 @@ class TestServerDisconnectHandling(unittest.TestCase): import server as _server_mod orig_handle_get = _server_mod.handle_get - orig_check_auth = _server_mod.check_auth + orig_check_auth = _server_mod.check_auth_or_close _server_mod.handle_get = _fake_handle_get - _server_mod.check_auth = lambda h, p: True + _server_mod.check_auth_or_close = lambda h, p: True closed_stdout = io.StringIO() closed_stdout.close() @@ -299,7 +299,7 @@ class TestServerDisconnectHandling(unittest.TestCase): finally: sys.stdout = original_stdout _server_mod.handle_get = orig_handle_get - _server_mod.check_auth = orig_check_auth + _server_mod.check_auth_or_close = orig_check_auth handler.send_response.assert_called_once_with(500) @@ -312,14 +312,14 @@ class TestServerDisconnectHandling(unittest.TestCase): import server as _server_mod orig_handle_get = _server_mod.handle_get - orig_check_auth = _server_mod.check_auth + orig_check_auth = _server_mod.check_auth_or_close _server_mod.handle_get = _fake_handle_get - _server_mod.check_auth = lambda h, p: True + _server_mod.check_auth_or_close = lambda h, p: True try: Handler.do_GET(handler) finally: _server_mod.handle_get = orig_handle_get - _server_mod.check_auth = orig_check_auth + _server_mod.check_auth_or_close = orig_check_auth # Should send 500 for real errors handler.send_response.assert_called_once_with(500) @@ -334,14 +334,14 @@ class TestServerDisconnectHandling(unittest.TestCase): import server as _server_mod orig_handle_get = _server_mod.handle_get - orig_check_auth = _server_mod.check_auth + orig_check_auth = _server_mod.check_auth_or_close _server_mod.handle_get = _fake_handle_get - _server_mod.check_auth = lambda h, p: True + _server_mod.check_auth_or_close = lambda h, p: True try: Handler.do_GET(handler) finally: _server_mod.handle_get = orig_handle_get - _server_mod.check_auth = orig_check_auth + _server_mod.check_auth_or_close = orig_check_auth handler.send_response.assert_not_called() @@ -355,14 +355,14 @@ class TestServerDisconnectHandling(unittest.TestCase): import server as _server_mod orig_handle_get = _server_mod.handle_get - orig_check_auth = _server_mod.check_auth + orig_check_auth = _server_mod.check_auth_or_close _server_mod.handle_get = _fake_handle_get - _server_mod.check_auth = lambda h, p: True + _server_mod.check_auth_or_close = lambda h, p: True try: Handler.do_GET(handler) finally: _server_mod.handle_get = orig_handle_get - _server_mod.check_auth = orig_check_auth + _server_mod.check_auth_or_close = orig_check_auth handler.send_response.assert_not_called() @@ -406,14 +406,14 @@ class TestServer500ResponseSafety(unittest.TestCase): import server as _server_mod orig_handle_get = _server_mod.handle_get - orig_check_auth = _server_mod.check_auth + orig_check_auth = _server_mod.check_auth_or_close _server_mod.handle_get = _fake_handle_get - _server_mod.check_auth = lambda h, p: True + _server_mod.check_auth_or_close = lambda h, p: True try: Handler.do_GET(handler) finally: _server_mod.handle_get = orig_handle_get - _server_mod.check_auth = orig_check_auth + _server_mod.check_auth_or_close = orig_check_auth # send_response WAS called (we tried to send 500), but write failed handler.send_response.assert_called_once_with(500) @@ -431,14 +431,14 @@ class TestServer500ResponseSafety(unittest.TestCase): import server as _server_mod orig_handle_get = _server_mod.handle_get - orig_check_auth = _server_mod.check_auth + orig_check_auth = _server_mod.check_auth_or_close _server_mod.handle_get = _fake_handle_get - _server_mod.check_auth = lambda h, p: True + _server_mod.check_auth_or_close = lambda h, p: True try: Handler.do_GET(handler) finally: _server_mod.handle_get = orig_handle_get - _server_mod.check_auth = orig_check_auth + _server_mod.check_auth_or_close = orig_check_auth # send_response WAS called (we tried to send 500), but write failed handler.send_response.assert_called_once_with(500) diff --git a/tests/test_comma_combined_content_length.py b/tests/test_comma_combined_content_length.py new file mode 100644 index 00000000..fc93f2a3 --- /dev/null +++ b/tests/test_comma_combined_content_length.py @@ -0,0 +1,105 @@ +"""Comma-combined ``Content-Length`` must not over-close a healthy connection. + +RFC 9110 §5.3 lets a list-valued field arrive either as repeated header lines or +as a single comma-combined line, and the two spellings are equivalent. So +``Content-Length: 0, 0`` describes the same bodyless request as two separate +``Content-Length: 0`` headers, and rejecting it must keep the keep-alive. + +``_declared_content_lengths()`` originally ran ``isdigit()`` across the whole raw +value, so the comma failed the check, the field read as *unreadable*, and every +helper-backed rejection armed ``Connection: close`` on a request with nothing +queued behind it. That is the over-close half of this PR's contract — the mirror +image of the bug it fixes, and the one that silently drops pooled connections. + +The dangerous direction has to stay closed, so the disagreeing and malformed +spellings are asserted here alongside the recoverable ones: ``0, 42`` genuinely +cannot frame a body and must still be refused with the close armed. +""" + +import pytest + +from api.helpers import request_declares_body, unreadable_content_length + + +class _Hdrs(dict): + """email.message-like: ``get()`` returns the first value, ``get_all()`` all.""" + + def get(self, k, default=None): + for key, val in self.items(): + if key.lower() == k.lower(): + return val[0] if isinstance(val, list) else val + return default + + def get_all(self, k, default=None): + out = [] + for key, val in self.items(): + if key.lower() == k.lower(): + out.extend(val if isinstance(val, list) else [val]) + return out or default + + +class _H: + def __init__(self, headers): + self.headers = headers + + +def _handler(cl): + return _H(_Hdrs({"Content-Length": cl})) + + +@pytest.mark.parametrize("value", ["0, 0", "00, 00", "0 , 0", "0,0", "0,\t0"]) +def test_agreeing_bodyless_comma_list_is_readable_and_bodyless(value): + """An agreeing all-zero list is framed, bodyless, and must keep keep-alive.""" + h = _handler(value) + assert unreadable_content_length(h) is False, ( + f"Content-Length {value!r} read as unreadable; a bodyless rejection would " + "then arm Connection: close and drop a healthy pooled connection" + ) + assert bool(request_declares_body(h)) is False, ( + f"Content-Length {value!r} declares no body, so nothing is queued" + ) + + +@pytest.mark.parametrize("value", ["42, 42", "42,42", "042, 42"]) +def test_agreeing_nonzero_comma_list_is_readable_with_a_body(value): + """An agreeing nonzero list frames a real body: readable, and a body IS pending.""" + h = _handler(value) + assert unreadable_content_length(h) is False, value + assert bool(request_declares_body(h)) is True, value + + +@pytest.mark.parametrize("value", ["0, 42", "42, 0", "0, 0, 42"]) +def test_disagreeing_comma_list_stays_unreadable(value): + """Disagreeing members cannot frame a body — must stay refused, close armed.""" + h = _handler(value) + assert unreadable_content_length(h) is True, ( + f"Content-Length {value!r} disagrees; there is no honest length to drain, " + "so it must be refused with the connection armed for close" + ) + assert bool(request_declares_body(h)) is True, value + + +@pytest.mark.parametrize( + "value", + ["0, banana", "banana, 0", "0,", ",0", "0, ,0", "0, +0", "0, \xa00", "0, 0x5"], +) +def test_malformed_comma_members_stay_unreadable(value): + """One bad member poisons the whole field: fail closed, never treat as zero.""" + h = _handler(value) + assert unreadable_content_length(h) is True, ( + f"Content-Length {value!r} has an unparseable member and must fail closed" + ) + assert bool(request_declares_body(h)) is True, value + + +def test_repeated_headers_and_comma_list_agree(): + """The two RFC spellings of the same field must behave identically.""" + repeated = _H(_Hdrs({"Content-Length": ["0", "0"]})) + combined = _handler("0, 0") + assert unreadable_content_length(repeated) == unreadable_content_length(combined) + assert bool(request_declares_body(repeated)) == bool(request_declares_body(combined)) + + repeated_bad = _H(_Hdrs({"Content-Length": ["0", "42"]})) + combined_bad = _handler("0, 42") + assert unreadable_content_length(repeated_bad) is True + assert unreadable_content_length(combined_bad) is True diff --git a/tests/test_get_with_body_auth_reject_reuse.py b/tests/test_get_with_body_auth_reject_reuse.py new file mode 100644 index 00000000..8caac837 --- /dev/null +++ b/tests/test_get_with_body_auth_reject_reuse.py @@ -0,0 +1,158 @@ +"""A body-bearing GET that fails auth must not poison a keep-alive connection. + +The sibling files cover the WRITE paths: a rejected POST/PUT/PATCH/DELETE arms the +close through ``check_auth_or_close``. ``do_GET`` was the documented residual — it +called plain ``check_auth``, so a GET that *declares a body* and then fails auth +answered its 401/302 and left those bytes queued in ``rfile``. On a keep-alive +socket the next request is then parsed starting mid-body, and the client gets an +error about a request it never sent. + +A GET with a body is unusual but entirely legal, and it is what a proxy, a +``fetch`` with a body, or a scripted client can produce. + +Both halves of the contract are asserted here, because the naive fix (close every +auth-failed GET) would regress the body-less login-redirect flow: + +* body-bearing + auth-failed -> connection MUST close (framing unresynchronisable) +* body-less + auth-failed -> connection MUST stay alive (nothing queued) + +``test_unfixed_handler_reproduces_the_poisoning`` is the negative control: the same +socket exchange against a handler that answers without arming the close shows the +leftover body surfacing as a bogus ``400 Bad request syntax``, proving the exchange +really does exercise the bug rather than passing vacuously. +""" + +import socket +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +BODY = b'{"q": "search"}' + + +def _get_with_body(path=b"/api/sessions"): + return ( + b"GET " + path + b" HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: " + str(len(BODY)).encode() + b"\r\n" + b"\r\n" + BODY + ) + + +BODYLESS_GET = ( + b"GET /api/sessions HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"\r\n" +) +FOLLOWING_GET = b"GET /api/health HTTP/1.1\r\nHost: localhost\r\n\r\n" + + +def _declares_body(handler): + """The production rule: framing declares a body, so it is queued in rfile.""" + raw = handler.headers.get("Content-Length") + if raw is not None: + stripped = raw.strip(" \t") + if not (stripped.isascii() and stripped.isdigit()): + return True # unreadable framing -> fail closed + try: + if int(stripped) > 0: + return True + except ValueError: + return True + return bool((handler.headers.get("Transfer-Encoding") or "").strip()) + + +class _AuthRejectingHandler(BaseHTTPRequestHandler): + """Mirrors do_GET's shape: reject before the body is ever read.""" + + protocol_version = "HTTP/1.1" + arm_close = True + + def do_GET(self): + if self.path == "/api/health": + payload = b'{"status": "ok"}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return + # Auth failure BEFORE the body is read — the residual's exact shape. + if self.arm_close and _declares_body(self): + self.close_connection = True + payload = b'{"error": "unauthorized"}' + self.send_response(401) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + if self.arm_close and _declares_body(self): + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *a): + pass + + +class _UnfixedHandler(_AuthRejectingHandler): + arm_close = False + + +def _exchange(handler_cls, request_bytes): + """Pipeline `request_bytes` + a follow-up GET down ONE socket; return the reply.""" + srv = ThreadingHTTPServer(("127.0.0.1", 0), handler_cls) + threading.Thread(target=srv.serve_forever, daemon=True).start() + try: + with socket.create_connection(srv.server_address, timeout=5) as s: + s.sendall(request_bytes + FOLLOWING_GET) + s.settimeout(5) + chunks = [] + while True: + try: + b = s.recv(4096) + except socket.timeout: + break + if not b: + break + chunks.append(b) + return b"".join(chunks) + finally: + srv.shutdown() + srv.server_close() + + +def test_body_bearing_get_that_fails_auth_closes_the_connection(): + raw = _exchange(_AuthRejectingHandler, _get_with_body()) + assert b"401" in raw, raw[:200] + assert b"Connection: close" in raw, f"401 did not advertise close: {raw[:200]}" + # The follow-up GET must NEVER be answered on this socket. + assert raw.count(b"HTTP/1.1") == 1, f"connection was reused: {raw[:300]}" + assert b'"status": "ok"' not in raw, f"follow-up GET was served: {raw[:300]}" + # And the leftover body must never surface as a bogus request line. + assert b"Bad request syntax" not in raw, raw[:300] + assert b"501" not in raw, raw[:300] + + +def test_bodyless_get_that_fails_auth_keeps_keep_alive(): + """The over-close half: nothing is queued, so the socket must stay usable.""" + raw = _exchange(_AuthRejectingHandler, BODYLESS_GET) + assert b"401" in raw, raw[:200] + assert b"Connection: close" not in raw, f"over-closed a bodyless reject: {raw[:200]}" + # The pipelined follow-up IS served, proving keep-alive survived. + assert b'"status": "ok"' in raw, f"keep-alive was dropped: {raw[:300]}" + assert raw.count(b"HTTP/1.1") == 2, raw[:300] + + +def test_unfixed_handler_reproduces_the_poisoning(): + """Negative control: without the arming, the leftover body corrupts the stream.""" + raw = _exchange(_UnfixedHandler, _get_with_body()) + assert b"401" in raw, raw[:200] + poisoned = ( + b"Bad request syntax" in raw + or b"400" in raw + or b"501" in raw + or b'"status": "ok"' not in raw + ) + assert poisoned, ( + "expected the unread body to poison the next request on this socket; " + f"got {raw[:300]!r}" + ) diff --git a/tests/test_issue1909_csp_report_only.py b/tests/test_issue1909_csp_report_only.py index a22be893..7ef947be 100644 --- a/tests/test_issue1909_csp_report_only.py +++ b/tests/test_issue1909_csp_report_only.py @@ -144,7 +144,7 @@ def test_server_bypasses_auth_for_csp_report(monkeypatch): called["path"] = parsed.path return True - monkeypatch.setattr("server.check_auth", fail_auth) + monkeypatch.setattr("server.check_auth_or_close", fail_auth) monkeypatch.setattr("server.clear_request_profile", lambda: None) monkeypatch.setattr("server.get_profile_cookie", lambda _handler: None) diff --git a/tests/test_production_do_get_body_pending.py b/tests/test_production_do_get_body_pending.py new file mode 100644 index 00000000..32929488 --- /dev/null +++ b/tests/test_production_do_get_body_pending.py @@ -0,0 +1,127 @@ +"""``Handler.do_GET`` itself must arm the close when a body is still queued. + +This is the production-wired companion to +``test_get_with_body_auth_reject_reuse.py``. That file proves the HTTP-level +consequence using a handler of the same shape; this one asserts the real +``server.Handler.do_GET`` routes its auth rejection through the body-pending-aware +path, so reverting ``server.py`` to plain ``check_auth`` fails a test. + +That distinction matters here: the PR's existing GET unit tests all pass with the +residual reverted, because none of them reach ``do_GET``'s auth call. +""" + +import types + +import pytest + + +def _handler_double(headers, path="/api/sessions"): + """A handler stub shaped like the one the reject paths receive.""" + h = types.SimpleNamespace() + h.path = path + h.headers = headers + h.close_connection = False + h.command = "GET" + h._headers_buffer = [] + h.responses = [] + h.sent = [] + return h + + +class _Hdrs(dict): + def get(self, k, default=None): + for key, val in self.items(): + if key.lower() == k.lower(): + return val[0] if isinstance(val, list) else val + return default + + def get_all(self, k, default=None): + out = [] + for key, val in self.items(): + if key.lower() == k.lower(): + out.extend(val if isinstance(val, list) else [val]) + return out or default + + +def test_do_get_source_uses_the_body_pending_aware_auth_call(): + """``do_GET`` must not call bare ``check_auth``. + + A source assertion is the right tool for this one: the call is a single line + inside a method whose surrounding machinery (socket, wfile, routing) would + have to be faked wholesale to reach it, and faking that is what let the + residual hide in the first place. + """ + import pathlib + + src = pathlib.Path(__file__).resolve().parents[1] / "server.py" + text = src.read_text() + + start = text.index("def do_GET") + end = text.index("def ", start + 10) + body = text[start:end] + + assert "check_auth_or_close(self, parsed)" in body, ( + "do_GET must route its auth rejection through check_auth_or_close so a " + "body-bearing GET cannot leave unread bytes on a keep-alive socket" + ) + assert "check_auth(self, parsed)" not in body, ( + "do_GET still calls bare check_auth — a body-bearing GET that fails auth " + "will poison the next request on the connection" + ) + + +def test_check_auth_or_close_arms_only_when_a_body_is_pending(): + """The over-close half, at the production helper.""" + from api.auth import check_auth_or_close + import api.auth as auth_mod + + calls = {} + + def _deny(handler, parsed): + calls["hit"] = True + return False + + real = auth_mod.check_auth + auth_mod.check_auth = _deny + try: + # Body pending -> close armed. + h = _handler_double(_Hdrs({"Content-Length": "15"})) + assert check_auth_or_close(h, None) is False + assert h.close_connection is True, "body-bearing reject must arm the close" + + # Body-less -> keep-alive preserved. + h2 = _handler_double(_Hdrs({})) + assert check_auth_or_close(h2, None) is False + assert h2.close_connection is False, ( + "a body-less reject must NOT close — that drops a healthy keep-alive" + ) + + # Unreadable framing -> fail closed. + h3 = _handler_double(_Hdrs({"Content-Length": "+0"})) + assert check_auth_or_close(h3, None) is False + assert h3.close_connection is True, ( + "unreadable Content-Length must fail closed, not be treated as zero" + ) + finally: + auth_mod.check_auth = real + + +@pytest.mark.parametrize( + "value", + ["\xa00", "0\x85", "+0", "-0", "0_0", "\u0660", "-5", ""], +) +def test_adversarial_zero_spellings_declare_a_body(value): + """Every spelling of "zero" that ``int()`` accepts must still fail closed. + + ``int()`` is far more generous than RFC 9110 ``Content-Length = 1*DIGIT``: + it tolerates unicode whitespace, a leading sign, PEP-515 underscores and + non-ASCII digits. Each of those parsed to an honest ``0`` would take the + keep-alive branch with the body still queued. + """ + from api.helpers import request_declares_body + + h = _handler_double(_Hdrs({"Content-Length": value})) + assert request_declares_body(h) is True, ( + f"Content-Length {value!r} was treated as no-body; a queued body would " + "then poison the next request on this connection" + )