fix(sdk): promote streaming-byte-guard to plugin-sdk + bound 4 core streaming reads#96688
Conversation
…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.
|
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. |
|
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:
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. |
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 aContent-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). Persrc/plugin-sdk/CLAUDE.md, extensions cannot importsrc/agents/**, soextensions/googleandextensions/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 asrc/agents/**reach-through.Changes
src/agents/streaming-byte-guard.ts(NEW, 108 lines) —createSseByteGuard(reader, {maxBytes, onOverflow?})wraps aReadableStreamDefaultReader<Uint8Array>, tracks accumulated bytes across the existing chunk loop, cancels the underlying reader on overflow, and throws a canonical overflow error. Exposesread() / 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-exportscreateSseByteGuard+SseStreamOverflow/SseByteGuard/ReadSseStreamWithLimitOptionstypes. Mirrors the existingopenclaw/plugin-sdk/provider-http(re-export ofreadProviderJsonResponsefromsrc/agents/provider-http-errors.ts) pattern that Alix-007 used to land the non-streaming bounded-read family.src/agents/anthropic-transport-stream.ts:56—parseAnthropicSseBodynow wrapsresponse.body.getReader()increateSseByteGuardwithANTHROPIC_MESSAGES_SUCCESS_BODY_MAX_BYTES = 16 * 1024 * 1024. Existing\n\nframe parsing, abort propagation, andreader.releaseLock()cleanup unchanged. Exports the function asparseAnthropicSseBodyForTestfor direct test access.src/llm/providers/anthropic.ts:18—iterateSseMessages(legacy provider's SSE parser) gets the same bound with its own constant.src/llm/providers/openai-chatgpt-responses.ts:29—parseSSE(ChatGPT Responses SSE parser) gets the same bound. Exports asparseSSEForTest.src/agents/runtime/proxy.ts:18—streamProxygets the same bound. The abort handler at line 162 also routes throughguard.cancel()so a bounded cancel propagates immediately on signal abort (with a fallback toreader.cancel()for the brief window before the guard is constructed).scripts/lib/plugin-sdk-entrypoints.json+package.jsonexports +docs/plugins/sdk-subpaths.md— register the new SDK subpathopenclaw/plugin-sdk/stream-reader-with-limit. Regenerated viapnpm plugin-sdk:sync-exports(not hand-edited).docs/.generated/plugin-sdk-api-baseline.sha256— regenerated viapnpm plugin-sdk:api:gen --writeto record the new SDK surface.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
ReadableStreammock 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.scripts/run-vitest.mjs; Node v22.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 0node scripts/run-tsgo.mjs— exit 0node scripts/sync-plugin-sdk-exports.mjs --check—plugin-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 newstream-reader-with-limit), public exports 10386 (+4), callable exports 5212 (+1)ReadableStreamDefaultReaderafter ~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.ReadableStreammock + in-memoryResponsemocks 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
readProviderJsonResponsefor non-streaming Anthropic/Anthropic-Vertex success bodies. The cap is consistent across non-streaming and streaming paths.createSseByteGuard.cancel(reason)propagates the overflow error to the underlying reader. Tests assertcancelReason instanceof Errorand the bounded pullCount.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).Diff stats
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).