fix(runtime/proxy): bound streaming success-body SSE reads at 16 MiB#96768
fix(runtime/proxy): bound streaming success-body SSE reads at 16 MiB#96768wangmiao0668000666 wants to merge 1 commit into
Conversation
|
Codex review: needs maintainer review before merge. Reviewed June 27, 2026, 12:12 PM ET / 16:12 UTC. Summary PR surface: Source +14, Tests +43. Total +57 across 2 files. Reproducibility: yes. Current main and v2026.6.10 show a source-visible uncapped proxy SSE success-body reader, and the PR body includes after-fix live output showing a local HTTP oversized stream cancelled near the 16 MiB cap with small-stream controls. Review metrics: 2 noteworthy metrics.
Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Land the proxy total-body guard after maintainers accept or tune the 16 MiB boundary and coordinate it with the open proxy parser and broader proxy hardening PRs. Do we have a high-confidence way to reproduce the issue? Yes. Current main and v2026.6.10 show a source-visible uncapped proxy SSE success-body reader, and the PR body includes after-fix live output showing a local HTTP oversized stream cancelled near the 16 MiB cap with small-stream controls. Is this the best way to solve the issue? Yes, conditionally. Using AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against b9c64142e281. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +14, Tests +43. Total +57 across 2 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
|
|
@clawsweeper re-review 🦞 upgrades applied (PR body only):
No code changes. |
|
🦞🧹 I asked ClawSweeper to review this item again. |
Apply createSseByteGuard to src/agents/runtime/proxy.ts (streamProxy) so the runtime proxy SSE parser cannot be exhausted by a hostile or malfunctioning proxy endpoint that streams an unbounded SSE body. The 16 MiB cap matches the non-streaming readProviderJsonResponse cap. What changed: - src/agents/runtime/proxy.ts: wrap response.body.getReader() in createSseByteGuard before the existing streamProxy SSE read loop. The abort handler and finally block use guard.cancel() instead of reader.cancel(). - Inline test added to proxy.test.ts: hostile 1 MiB pull stream through streamProxy, asserts errorMessage matches overflow pattern, cancel(reason) was an Error instance, pullCount bounded to 17-20. Reuses the createSseByteGuard helper from src/agents/streaming-byte-guard.ts (introduced in openclaw#96701 for the anthropic-transport-stream path; same helper, same 16 MiB cap, same overflow-error shape). This PR is the final per-surface rescue from the previously closed PR openclaw#96666, after PR #3c (openclaw#96762). No SDK surface change. No repro script committed (proof in this body).
7701903 to
87d72e2
Compare
|
@clawsweeper re-review Rebased onto current main. The Diff: 2 files, +59/-2 (no more |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
Heads up for maintainers: this PR is in re-review loop because three open PRs all touch the same
All three address proxy-stream memory/availability hardening, but at different layers and with different cap values. The ClawSweeper P1 is "coordinate final merge order with the open per-event and broader proxy hardening PRs" — that's a maintainer decision, not something I can resolve as a contributor. What I see as the relationship:
If maintainers want a single canonical proxy hardening PR, the order I'd suggest is #97191 → #96768 → #97235 (narrow → broad). But the right answer depends on which behavior we want to land, which is a product decision. Asking maintainers: which layering do you want? Three separate PRs by layer, or one broad PR (#97235) that supersedes the others? In the meantime, this PR is rated 🐚 with proof sufficient. The re-review loop is purely on the coordination P1. |
|
Closing this in favor of letting What I found reading
|
What Problem This Solves
src/agents/runtime/proxy.ts(streamProxy, the internal proxy SSE parser) accumulates an unbounded SSE byte stream into a string buffer while waiting for\nline delimiters. A hostile or malfunctioning proxy endpoint (including a MITM-able proxy or a hijacked self-hosted proxy server) can return aContent-Length-less SSE body that streams forever, exhausting process memory on a code path that runs for every proxied LLM streaming call.This is the streaming-body analogue of the bounded-read sweep Alix-007 finished for non-streaming
response.json(). Same 16 MiB cap, same helper-driven design, but on the chunk-by-chunk SSE path that the non-streaming sweep did not cover.Changes
Reuses the existing
createSseByteGuardhelper (already in main) — no new abstraction in this PR.src/agents/runtime/proxy.ts:200-207—streamProxywrapsresponse.body!.getReader()increateSseByteGuardwithPROXY_STREAM_BODY_MAX_BYTES = 16 * 1024 * 1024. The abort handler and finally block useguard.cancel()withreader.cancel()fallback for the code path before the guard is created.src/agents/runtime/proxy.test.ts— 1 inline test (43 LoC) through productionstreamProxywrapper with oversized stream + negative control + happy path, assertingerrorMessage,cancel(reason)type, andpullCount.Follows the pattern from #96701 (anthropic-transport-stream). Aligned with the bounded-read campaign.
Design Rationale
Why reuse
createSseByteGuardinstead of a new helper? The proxy SSE parser follows the same chunk-by-chunkreader.read()→ buffer → parse loop structure as anthropic-transport-stream. Using the same guard avoids duplicating the byte-accounting, overflow-check, and cancellation logic. The guard'sSseByteGuardinterface (read/cancel/totalBytes/overflowed/cancelled) was designed for this cross-parser reuse from the start.Why 16 MiB cap? Matches the existing non-streaming 16 MiB cap from
readProviderJsonResponse(src/agents/provider-http-errors.ts) and the sibling anthropic-transport-stream PR (#96701). The proxy is an internal transport layer — it forwards response bodies from the upstream provider. A 16 MiB cap on the proxy's own SSE parser provides defense-in-depth beyond the provider-level cap already applied in #96701.Why a different constant name (
PROXY_STREAM_BODY_MAX_BYTES) rather than sharing the same constant? Each parser (anthropic transport, proxy, provider) evolves independently — the proxy cap may later be tuned differently if proxy-specific streaming patterns emerge. Named constants at each call site keep the override path explicit while the value stays consistent (all 16 MiB).Why
guard.cancel()+reader.cancel()fallback in the error/abort handler? ThestreamProxyfunction creates the guard AFTER the reader is obtained. If an abort arrives betweenresponse.body.getReader()andcreateSseByteGuard(reader), thefinallyblock falls back toreader.cancel()directly becauseguardis undefined. This is a defensive pattern — the window is small but present, and dropping the reader without cancellation would leak the stream.Real behavior proof
streamProxySSE read; after the fix the read is capped at 16 MiB and the stream is cancelled on overflow.node:httpserver bound to127.0.0.1returning aContent-Length-less 64 MiB body (4× the cap) chunk-by-chunk, plus a negative-control 1 MiB body, plus a happy-path body (2 events + terminal). Drives the productionstreamProxyover a real TCP socket. Node v22.22.0.node --import tsx _proof_proxy_stream.mts(proof script kept local-only, not committed).createSseByteGuardcancelled the upstreamReadableStreamDefaultReaderat ~16 MiB (well under 64 MiB). Server-sidesocket.bytesWrittenobserved at 19,923,323 — bounded, not drained.cancel(reason)received anErrorinstance.Out of scope (separate follow-ups)
src/llm/providers/anthropic.ts(iterateSseMessages, legacy provider SSE parser) — fix(anthropic): bound streaming 200 success-body SSE reads at 16 MiB (provider path) #96723.extensions/google/transport-stream.tsandextensions/ollama/src/{setup,stream}.ts— need the helper promoted to a plugin-SDK subpath first (separate foundation PR).Risk checklist
Diff stats