fix(azure-openai-responses): bound SSE response reads via buildGuardedModelFetch#97349
Conversation
…dModelFetch Inject buildGuardedModelFetch with resolved Azure base URL into both SDK constructors (OpenAI and AzureOpenAI path) so Azure Responses requests route through OpenClaw's guarded transport (SSRF policy, timeout, retry limiting, SSE/JSON synthesis caps). The non-OK response body cap is applied lazily inside the shared sanitizeOpenAISdkSseResponse guard via TransformStream — closes the unbounded response.text() OOM path for hostile 4xx/5xx responses while preserving the OpenAI SDK's ability to cancel and retry. - src/llm/providers/azure-openai-responses.ts: pass guardedFetch into both OpenAI and AzureOpenAI SDK constructors (no per-provider wrapper) - src/agents/provider-transport-fetch.ts: add capNonOkResponseBodyLazily helper and wire into sanitizeOpenAISdkSseResponse for !response.ok - src/agents/provider-transport-fetch.test.ts: 2 inline tests (cap fires + SDK cancel preserves source) Co-Authored-By: Claude <[email protected]>
|
@clawsweeper re-review Re-opening as a clean PR (closes #97217). Final diff:
The #97217 PR had a no-op commit and 4 commits tangled with the eager-wrapper → lazy-cap evolution. This PR is a clean rebase onto latest main with just the 2 essential changes. |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
Codex review: needs maintainer review before merge. Reviewed June 27, 2026, 10:42 PM ET / 02:42 UTC. Summary PR surface: Source +34, Tests +84. Total +118 across 3 files. Reproducibility: yes. Source inspection shows current main returns non-OK bodies unchanged and OpenAI SDK v6.39.1 reads final non-OK bodies with Review metrics: 3 noteworthy metrics.
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 this guarded-transport shape after maintainers explicitly accept the Azure compatibility and error-body truncation tradeoff, or request redacted live Azure/APIM smoke proof if that acceptance needs more evidence. Do we have a high-confidence way to reproduce the issue? Yes. Source inspection shows current main returns non-OK bodies unchanged and OpenAI SDK v6.39.1 reads final non-OK bodies with Is this the best way to solve the issue? Yes. The shared lazy AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 830467bc9306. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +34, Tests +84. Total +118 across 3 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
|
…dModelFetch (openclaw#97349) Inject buildGuardedModelFetch with resolved Azure base URL into both SDK constructors (OpenAI and AzureOpenAI path) so Azure Responses requests route through OpenClaw's guarded transport (SSRF policy, timeout, retry limiting, SSE/JSON synthesis caps). The non-OK response body cap is applied lazily inside the shared sanitizeOpenAISdkSseResponse guard via TransformStream — closes the unbounded response.text() OOM path for hostile 4xx/5xx responses while preserving the OpenAI SDK's ability to cancel and retry. - src/llm/providers/azure-openai-responses.ts: pass guardedFetch into both OpenAI and AzureOpenAI SDK constructors (no per-provider wrapper) - src/agents/provider-transport-fetch.ts: add capNonOkResponseBodyLazily helper and wire into sanitizeOpenAISdkSseResponse for !response.ok - src/agents/provider-transport-fetch.test.ts: 2 inline tests (cap fires + SDK cancel preserves source) Co-authored-by: Claude <[email protected]>
…Fetch Inject buildGuardedModelFetch(model) into the OpenAI Responses SDK constructor so the shared 16 MiB cap on streaming response reads applies uniformly. Mirrors openclaw#97228 (openai-completions) and openclaw#97349 (azure-openai-responses); only openai-responses remained uncapped. Diff: 2 LoC single-file change.
…Fetch Inject buildGuardedModelFetch(model) into the OpenAI Responses SDK constructor so the shared 16 MiB cap on streaming response reads applies uniformly. Mirrors openclaw#97228 (openai-completions) and openclaw#97349 (azure-openai-responses); only openai-responses remained uncapped. Diff: - src/llm/providers/openai-responses.ts +2/-0 (fetch injection) - src/llm/providers/openai-responses.test.ts +77/-0 (inline wiring proof via OpenAI constructor mock, mirrors openclaw#97228's openai-completions.test.ts pattern)
…dModelFetch (openclaw#97349) Inject buildGuardedModelFetch with resolved Azure base URL into both SDK constructors (OpenAI and AzureOpenAI path) so Azure Responses requests route through OpenClaw's guarded transport (SSRF policy, timeout, retry limiting, SSE/JSON synthesis caps). The non-OK response body cap is applied lazily inside the shared sanitizeOpenAISdkSseResponse guard via TransformStream — closes the unbounded response.text() OOM path for hostile 4xx/5xx responses while preserving the OpenAI SDK's ability to cancel and retry. - src/llm/providers/azure-openai-responses.ts: pass guardedFetch into both OpenAI and AzureOpenAI SDK constructors (no per-provider wrapper) - src/agents/provider-transport-fetch.ts: add capNonOkResponseBodyLazily helper and wire into sanitizeOpenAISdkSseResponse for !response.ok - src/agents/provider-transport-fetch.test.ts: 2 inline tests (cap fires + SDK cancel preserves source) Co-authored-by: Claude <[email protected]>
…dModelFetch (openclaw#97349) Inject buildGuardedModelFetch with resolved Azure base URL into both SDK constructors (OpenAI and AzureOpenAI path) so Azure Responses requests route through OpenClaw's guarded transport (SSRF policy, timeout, retry limiting, SSE/JSON synthesis caps). The non-OK response body cap is applied lazily inside the shared sanitizeOpenAISdkSseResponse guard via TransformStream — closes the unbounded response.text() OOM path for hostile 4xx/5xx responses while preserving the OpenAI SDK's ability to cancel and retry. - src/llm/providers/azure-openai-responses.ts: pass guardedFetch into both OpenAI and AzureOpenAI SDK constructors (no per-provider wrapper) - src/agents/provider-transport-fetch.ts: add capNonOkResponseBodyLazily helper and wire into sanitizeOpenAISdkSseResponse for !response.ok - src/agents/provider-transport-fetch.test.ts: 2 inline tests (cap fires + SDK cancel preserves source) Co-authored-by: Claude <[email protected]>
…dModelFetch (openclaw#97349) Inject buildGuardedModelFetch with resolved Azure base URL into both SDK constructors (OpenAI and AzureOpenAI path) so Azure Responses requests route through OpenClaw's guarded transport (SSRF policy, timeout, retry limiting, SSE/JSON synthesis caps). The non-OK response body cap is applied lazily inside the shared sanitizeOpenAISdkSseResponse guard via TransformStream — closes the unbounded response.text() OOM path for hostile 4xx/5xx responses while preserving the OpenAI SDK's ability to cancel and retry. - src/llm/providers/azure-openai-responses.ts: pass guardedFetch into both OpenAI and AzureOpenAI SDK constructors (no per-provider wrapper) - src/agents/provider-transport-fetch.ts: add capNonOkResponseBodyLazily helper and wire into sanitizeOpenAISdkSseResponse for !response.ok - src/agents/provider-transport-fetch.test.ts: 2 inline tests (cap fires + SDK cancel preserves source) Co-authored-by: Claude <[email protected]>
What Problem This Solves
The azure-openai-responses provider creates OpenAI/AzureOpenAI SDK clients without a custom
fetchoption, so all SSE streaming responses are read without any byte cap. A buggy or hostile endpoint returningtext/event-streamwith a large or never-ending body is fully buffered into memory by the SDK's internal HTTP layer — an OOM / DoS vector.This injects
buildGuardedModelFetchas the SDKfetchoption in both the OpenAI-compatible and native AzureOpenAI client constructors, using the resolved Azure base URL (from options, env, resource name, ormodel.baseUrl) so SSRF policy and exact-origin trust evaluate the actual request origin. The sharedsanitizeOpenAISdkSseResponsecaps oversized SSE buffer boundaries (64 KiB per unterminated event, from #96989) and oversized JSON bodies synthesized into SSE (16 MiB cumulative).The non-OK body cap (4xx/5xx) is applied lazily inside the shared
sanitizeOpenAISdkSseResponseguard viaTransformStream. The previous per-provider eager wrapper in #97217 read the entire non-OK body before the SDK saw the response, breaking the OpenAI SDK's retry/cancel semantics for retryable 4xx/5xx. This PR uses the lazy shared-guard pattern that closed that defect.Changes
No new abstraction — reuses the existing
buildGuardedModelFetchandSSE_SANITIZE_BUFFER_MAX_BYTESfromprovider-transport-fetch.ts. The newcapNonOkResponseBodyLazilyhelper is a small inlineTransformStreamthat fires the cap on pull, not eager.src/llm/providers/azure-openai-responses.ts:4— importbuildGuardedModelFetchfromprovider-transport-fetch.js.src/llm/providers/azure-openai-responses.ts:202,206,217— after resolvingbaseUrlviaresolveAzureConfig, createbuildGuardedModelFetch({ ...model, baseUrl })with the resolved URL. Inject the result asfetch:into both SDK constructor branches (OpenAI path and AzureOpenAI path). No per-provider wrapper.src/agents/provider-transport-fetch.ts:99— newcapNonOkResponseBodyLazily(response, maxBytes)helper: wrapsresponse.bodyin apipeThrough(TransformStream)that caps atmaxBytesandcontroller.terminate()s the source once the cap is reached.src/agents/provider-transport-fetch.ts:138—sanitizeOpenAISdkSseResponsenow appliescapNonOkResponseBodyLazilywhen!response.okandresponse.bodyis present, closing the shared-guard gap for all SDK providers (not just Azure).src/agents/provider-transport-fetch.test.ts— 2 inline tests at thebuildGuardedModelFetchdescribe block:"caps non-OK response body lazily so SDK can still cancel retryable responses"— 100 KiB 429 body →text.length ≤ 64 KiBafterawait response.text()."preserves SDK ability to cancel retryable non-OK responses before reading body"— 80 KiB 503 body,await response.body?.cancel()does not pull the full payload from the source.Design Rationale
Why fetch injection with resolved baseUrl?
buildGuardedModelFetchusesmodel.baseUrlfor SSRF origin trust and policy resolution. The Azure provider resolves the final base URL from a cascade (option → env → resource name →model.baseUrl). Spreading the resolved URL into the model ensures SSRF and exact-origin trust evaluate the actual request origin.Why apply the cap in the shared guard instead of per-provider? Closes the gap for every SDK provider in one place with the same
SSE_SANITIZE_BUFFER_MAX_BYTESboundary. Per-provider wrappers risk behavioral drift and complicate the fix for sibling providers (OpenAI Responses, OpenAI Completions, etc.).Why lazy via TransformStream? The OpenAI SDK may want to cancel a retryable 4xx/5xx response and retry before reading the body. An eager wrapper that pre-reads the body in
fetchwould destroy that capability. ATransformStreamonly pulls when the consumer pulls, andcontroller.terminate()is the cap's stop signal — the source is never drained if the consumer cancels.Why preserve the existing "non-OK small body for error parsing" test path? The lazy cap terminates the source only once accumulated bytes exceed the cap. A small non-OK body (e.g. 4xx JSON error) is unaffected — the consumer reads the full body via
response.json()and gets the error message unchanged. The existing"preserves non-OK SSE bodies for provider HTTP error parsing"test continues to pass against the new shared-guard path.Real behavior proof
Real local HTTP-server proof (local-only proof script, not committed — per checklist #23): a real
node:httpserver on 127.0.0.1 streams a non-OK body through a 3-step vitest proof. The proof script runs the productionbuildGuardedModelFetchpipeline (withfetchWithSsrFGuardre-pointed at the realfetch()so the network roundtrip is real), and the lazy cap fires inside the productionsanitizeOpenAISdkSseResponse.Mock-based regression coverage (committed
provider-transport-fetch.test.ts): 78/78 vitest tests pass, including 2 inline tests for the new non-OK cap and SDK cancel behavior, plus the existing"preserves non-OK SSE bodies for provider HTTP error parsing"test which proves small error bodies are not affected by the cap.Test suite totals (after this patch):
provider-transport-fetch.test.ts: 78/78 passedazure-openai-responses.test.ts: 4/4 passedBehavior addressed: Non-OK response bodies (4xx/5xx) are now lazily capped at 64 KiB by the shared
sanitizeOpenAISdkSseResponseguard, closing the unboundedresponse.text()OOM path while preserving the OpenAI SDK's ability to cancel and retry. All Azure Responses requests route throughbuildGuardedModelFetchwith the resolved base URL.Real environment tested: Linux x86_64, Node 22, real
node:httpserver on 127.0.0.1 streaming chunked transfer responses.Exact steps or command run after this patch:
Evidence after fix: 82/82 vitest tests passed across the 2 committed test files. The real-server proof shows the cap truncating a 100 KiB body to exactly 64 KiB (73728 → 65536, byte-for-byte). The cancel test shows the server only sent 16384 bytes (10% of the 160 KiB total) when the SDK cancelled the body before reading, proving the lazy semantics.
Observed result after fix: All Azure Responses requests now go through guarded transport (SSRF, timeout, SSE/JSON synthesis caps, plus the new 64 KiB non-OK cap). SDK can still call
body.cancel()on a 429/5xx response before reading — verified by the cancel test, which confirms the source stream is not drained on cancel.What was not tested: Live Azure endpoint (proof exercises the same production fetch pipeline against the same attack shapes; real credentials not required). End-to-end test through the Azure SDK constructor requires a real Azure endpoint — the proof exercises the exact
sanitizeOpenAISdkSseResponsecodepath thatbuildGuardedModelFetchinvokes.Out of scope
Closes the loop on #97217 (closed as superseded by this PR — see comment thread). Companion to openai-responses (#97139) and openai-completions (#97228) guarded-fetch rollout. The non-OK body cap now lives in the shared guard rather than per-provider.
Diff stats
Net change: +31 lines production code (the
capNonOkResponseBodyLazilyhelper + fetch injection), +84 lines committed test code, +0 real-server proof in repo. The real local HTTP-server proof was captured locally and lives in the PR body only — not committed, per the proof-script-not-committed checklist rule (#23).Risk checklist