Skip to content

fix(sdk): promote streaming-byte-guard to plugin-sdk + bound 4 core streaming reads#96688

Closed
wangmiao0668000666 wants to merge 1 commit into
openclaw:mainfrom
wangmiao0668000666:fix/sdk-stream-reader-with-limit
Closed

fix(sdk): promote streaming-byte-guard to plugin-sdk + bound 4 core streaming reads#96688
wangmiao0668000666 wants to merge 1 commit into
openclaw:mainfrom
wangmiao0668000666:fix/sdk-stream-reader-with-limit

Conversation

@wangmiao0668000666

Copy link
Copy Markdown
Contributor

What Problem This Solves

Streaming-body SSE reads in OpenClaw's core runtime (Anthropic transport, Anthropic provider, OpenAI Codex Responses, internal proxy) accumulate unbounded bytes into a string buffer while waiting for frame delimiters. The companion error-body path (readAnthropicMessagesErrorBodySnippet, 8 KiB cap + 10s idle timeout) was bounded by #95108; the symmetric 200 success-body path was left open. A hostile or malfunctioning Anthropic-compatible endpoint, ChatGPT Responses mirror, or internal proxy can return a Content-Length-less SSE body that streams forever, exhausting process memory on every streaming model call.

The bounded helper (createSseByteGuard) was previously only in core (src/agents/streaming-byte-guard.ts). Per src/plugin-sdk/CLAUDE.md, extensions cannot import src/agents/**, so extensions/google and extensions/ollama (the next streaming-body OOM surfaces) had no way to reuse the bounded-read helper. This PR promotes the helper to a public plugin-SDK subpath so the future extensions can import it without a src/agents/** reach-through.

Changes

  • src/agents/streaming-byte-guard.ts (NEW, 108 lines) — createSseByteGuard(reader, {maxBytes, onOverflow?}) wraps a ReadableStreamDefaultReader<Uint8Array>, tracks accumulated bytes across the existing chunk loop, cancels the underlying reader on overflow, and throws a canonical overflow error. Exposes read() / cancel() / totalBytes() / overflowed() so the same guard drives both production and AbortSignal-handling code paths.
  • src/plugin-sdk/stream-reader-with-limit.ts (NEW, 16 lines) — re-exports createSseByteGuard + SseStreamOverflow / SseByteGuard / ReadSseStreamWithLimitOptions types. Mirrors the existing openclaw/plugin-sdk/provider-http (re-export of readProviderJsonResponse from src/agents/provider-http-errors.ts) pattern that Alix-007 used to land the non-streaming bounded-read family.
  • src/agents/anthropic-transport-stream.ts:56parseAnthropicSseBody now wraps response.body.getReader() in createSseByteGuard with ANTHROPIC_MESSAGES_SUCCESS_BODY_MAX_BYTES = 16 * 1024 * 1024. Existing \n\n frame parsing, abort propagation, and reader.releaseLock() cleanup unchanged. Exports the function as parseAnthropicSseBodyForTest for direct test access.
  • src/llm/providers/anthropic.ts:18iterateSseMessages (legacy provider's SSE parser) gets the same bound with its own constant.
  • src/llm/providers/openai-chatgpt-responses.ts:29parseSSE (ChatGPT Responses SSE parser) gets the same bound. Exports as parseSSEForTest.
  • src/agents/runtime/proxy.ts:18streamProxy gets the same bound. The abort handler at line 162 also routes through guard.cancel() so a bounded cancel propagates immediately on signal abort (with a fallback to reader.cancel() for the brief window before the guard is constructed).
  • scripts/lib/plugin-sdk-entrypoints.json + package.json exports + docs/plugins/sdk-subpaths.md — register the new SDK subpath openclaw/plugin-sdk/stream-reader-with-limit. Regenerated via pnpm plugin-sdk:sync-exports (not hand-edited).
  • docs/.generated/plugin-sdk-api-baseline.sha256 — regenerated via pnpm plugin-sdk:api:gen --write to record the new SDK surface.
  • 3 test files: src/agents/anthropic-transport-stream.test.ts (+76), src/llm/providers/openai-chatgpt-responses.test.ts (+96), src/agents/runtime/proxy.test.ts (+38) — inline overflow + happy-path tests for each bounded surface. No committed repro script (proof in this body, not in git — matches Alix-007's pattern in fix(agents): bound provider JSON response reads #95218 / fix(agents): bound Google prompt cache response reads #95417 / fix(ollama): bound model-discovery JSON response reads #96027 / fix(exa): bound untrusted search JSON response reads #96038 / fix(lmstudio): bound model load success response body to prevent OOM #96042 / fix(github-copilot): bound usage response reads #96607).

Real behavior proof

The 4 bounded surfaces are exercised against an in-test ReadableStream mock that pulls 1 MiB chunks until the bounded reader cancels. Overflow path asserts the canonical overflow message + the bounded reader cancel call. Negative control confirms small bodies drain fully.

  • Behavior addressed: unbounded buffering of core streaming-body SSE reads (Anthropic transport, Anthropic provider, OpenAI Codex Responses, internal proxy); after the fix all 4 reads are capped at 16 MiB and streams are cancelled on overflow.
  • Real environment tested: vitest 4.1.7 via scripts/run-vitest.mjs; Node v22.
  • Exact steps:
    • node scripts/run-vitest.mjs anthropic-transport-stream anthropic.test openai-chatgpt-responses proxy.test --run — 84/84 passed (4 files)
    • node scripts/run-oxlint.mjs --tsconfig tsconfig.json <files> — exit 0
    • node scripts/run-tsgo.mjs — exit 0
    • node scripts/sync-plugin-sdk-exports.mjs --checkplugin-sdk exports synced.
    • node --max-old-space-size=8192 --import tsx scripts/generate-plugin-sdk-api-baseline.ts --check — exit 0 (baseline hash matches regenerated)
    • node scripts/plugin-sdk-surface-report.mjs --check — public entrypoints 323 (+1, matches new stream-reader-with-limit), public exports 10386 (+4), callable exports 5212 (+1)
  • Observed result after fix: bounded readers cancel upstream ReadableStreamDefaultReader after ~17-20 chunks (1 MiB each) before draining the full hostile body. Test pullCount assertions bounded to that range. Small valid SSE frames still parse end-to-end.
  • What was not tested: live API calls to real Anthropic / ChatGPT Responses / proxy endpoints (in-test ReadableStream mock + in-memory Response mocks are equivalent transport behavior). No cross-platform Node 18/20 differences tested (Node 22 only, matches CI).

Out of scope (separate PRs)

  • extensions/google/transport-stream.ts — needs to import the new SDK subpath to bound its streaming Anthropic-compatible response. Separate PR after this lands.
  • extensions/ollama/src/{setup,stream}.ts — same pattern for Ollama's streaming chat + model-discovery reads. Separate PR after this lands.

Risk

  • Low: same 16 MiB cap already used by readProviderJsonResponse for non-streaming Anthropic/Anthropic-Vertex success bodies. The cap is consistent across non-streaming and streaming paths.
  • Cancel propagation: createSseByteGuard.cancel(reason) propagates the overflow error to the underlying reader. Tests assert cancelReason instanceof Error and the bounded pullCount.
  • SDK surface: ONE new public subpath openclaw/plugin-sdk/stream-reader-with-limit (mirrors the Alix-007 SDK re-export pattern). Full metadata regenerated via the proper SDK sync workflow (not hand-edited).
  • Maintainer review requested: SDK promotion needs explicit acceptance per the P1 ClawSweeper flag from the previous PR. This PR does the SDK sync properly (sync-exports + api:gen + surface:check all green) so the maintainer gate is the only outstanding concern.

Diff stats

12 files changed, 419 insertions(+), 14 deletions(-)

Per-surface breakdown (excluding helper + SDK re-export + plumbing):

  • src/agents/anthropic-transport-stream.ts: +26/-7 (cap constant + import + wrap)
  • src/llm/providers/anthropic.ts: +14/-3 (cap constant + import + wrap)
  • src/llm/providers/openai-chatgpt-responses.ts: +22/-2 (cap constant + import + wrap + export)
  • src/agents/runtime/proxy.ts: +17/-2 (cap constant + import + wrap + abort-handler route)

Plus 1 helper (108 lines) + 1 SDK re-export (16 lines) + 1 docs row (1 line) + 2 metadata lines (entrypoints + baseline) + 3 test files (+210/-0 inline tests).

…treaming reads

This is the foundation PR for Alix-007-style per-surface bounded-read
work. It bundles the SDK promotion with the 4 core streaming-body
consumers because the helper (108 lines) is meaningless without a
consumer proving the SDK seam works end-to-end.

What landed:
- New helper src/agents/streaming-byte-guard.ts (108 lines) — wraps a
  ReadableStreamDefaultReader<Uint8Array>, tracks accumulated bytes
  across the existing chunk loop, cancels the underlying reader on
  overflow, and throws a canonical overflow error.
- New SDK re-export src/plugin-sdk/stream-reader-with-limit.ts (16 lines) —
  exposes createSseByteGuard + the 3 type aliases to extensions.
- 4 core streaming-body surfaces bounded at 16 MiB:
  - src/agents/anthropic-transport-stream.ts (parseAnthropicSseBody)
  - src/llm/providers/anthropic.ts (iterateSseMessages)
  - src/llm/providers/openai-chatgpt-responses.ts (parseSSE)
  - src/agents/runtime/proxy.ts (streamProxy)
- Full SDK plumbing: scripts/lib/plugin-sdk-entrypoints.json + package.json
  exports (regenerated via pnpm plugin-sdk:sync-exports) + docs row.
- Baseline hash regenerated via pnpm plugin-sdk:api:gen.
- Inline tests for each bounded surface (overflow + happy path).
- No committed repro script (Alix-007 pattern — proof in body, not git).

Mirrors the existing openclaw/plugin-sdk/provider-http (re-exporting
readProviderJsonResponse from src/agents/provider-http-errors.ts)
pattern that Alix-007 used to land the bounded-read family.

Stacked-on: none (independent PR). Builds on existing core helper shape.
Stacks: extensions/google + extensions/ollama bounded-read PRs will land
after this foundation, importing from openclaw/plugin-sdk/stream-reader-with-limit.
@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation scripts Repository scripts agents Agent runtime and tooling size: M triage: needs-pr-context Candidate: external PR body lacks required problem context or evidence. labels Jun 25, 2026
@clawsweeper

clawsweeper Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

ClawSweeper status: review started.

I am starting a fresh review of this pull request: fix(sdk): promote streaming-byte-guard to plugin-sdk + bound 4 core streaming reads This is item 1/1 in the current shard. Shard 0/1.

This placeholder means the worker is alive and reading the current context. I will edit this same comment with the actual review when the claws are done clicking.

Crustacean status: shell secured, claws on keyboard, evidence pebbles being sorted.

@wangmiao0668000666

Copy link
Copy Markdown
Contributor Author

Closing — the new Foundation PR is too big (12 files, 419 LoC, M size label). Re-splitting into 5 Alix-007-style XS PRs that each touch 2–3 files and <100 LoC.

Split plan:

  • PR #3a (Foundation, ~4 files, ~130 LoC): helper + SDK re-export + SDK plumbing only. No call site changes, no tests.
  • PR #3b (anthropic-transport-stream, 2 files, ~70 LoC): apply helper to one file + 1 inline test.
  • PR #3c (anthropic provider, 2 files, ~50 LoC): same pattern for the legacy provider.
  • PR #3d (openai-chatgpt-responses, 2 files, ~80 LoC): same pattern for ChatGPT Responses.
  • PR #3e (runtime/proxy, 2 files, ~55 LoC): same pattern for the internal proxy.

The helper + SDK plumbing is shared foundation; each surface PR is a self-contained per-surface application matching the bounded-read family pattern Alix-007 used in #96607 / #96042 / #96038 / #96027 / #96035.

Each per-surface PR will explicitly cite #3a as a prerequisite in its body and rebase onto main once #3a lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling docs Improvements or additions to documentation scripts Repository scripts size: M triage: needs-pr-context Candidate: external PR body lacks required problem context or evidence.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant