fix(inworld): bound TTS audio, voices, and error response reads to prevent OOM#95416
Conversation
|
Codex review: needs maintainer review before merge. Reviewed June 28, 2026, 8:02 PM ET / 00:02 UTC. Summary PR surface: Source +73, Tests +125. Total +198 across 2 files. Reproducibility: yes. by source inspection: current main and Review metrics: 2 noteworthy metrics.
Stored data model 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. Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Land the bounded Inworld response-read fix after maintainers accept the fail-closed caps as the provider contract and the latest-head required checks pass or are explicitly waived. Do we have a high-confidence way to reproduce the issue? Yes by source inspection: current main and Is this the best way to solve the issue? Yes for the runtime fix: bounding at the Inworld HTTP boundary with the existing shared reader is the narrow maintainable solution, and the latest head also enforces the decoded-audio cap. The remaining question is maintainer acceptance of the fail-closed limits as provider policy. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 305fa9c4ddbe. Label changesLabel justifications:
Evidence reviewedPR surface: Source +73, Tests +125. Total +198 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 — Addressed both P1s: (1) refreshed |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
CI note after re-checking the failing shards: the visible The PR-specific follow-up already refreshed the SDK API baseline and the focused Inworld tests remain the relevant validation for this branch. The remaining blocker appears to be stale/base CI plus maintainer review, not an Inworld code-path failure. |
25beec3 to
75308ee
Compare
|
Maintenance update: rebased this branch linearly onto the latest upstream/main. This drops the generated plugin SDK baseline file from the PR diff because current main already carries the matching baseline state; the remaining diff is limited to Inworld TTS bounded reads plus the response-limit runtime export. Author/committer email is [email protected]. |
|
Follow-up CI fix: the remaining tooling failure was in Verified locally with: The new |
1736362 to
6a1f212
Compare
|
Maintenance update: pushed a non-force follow-up commit to align the plugin SDK surface budgets with current Verified locally:
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
Merged latest upstream Verified current head locally:
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
0d93157 to
50005fc
Compare
50005fc to
dde7141
Compare
|
Pushed a small maintainer follow-up in Focused validation passed locally: Leaving this open for the fresh current-head checks to finish before merge. |
What Problem This Solves
Resolves a problem where the Inworld text-to-speech integration would buffer an
unbounded, externally controlled HTTP response into memory, letting a
misbehaving, compromised, or hostile Inworld endpoint (including a self-hosted
baseUrloverride) exhaust the gateway's memory or hang it.The Inworld extension read four externally controlled response bodies with no
byte ceiling:
extensions/inworld/tts.ts— the streaming TTS audio body viaawait response.text(), before splitting newline-delimited JSON andconcatenating base64 audio chunks. A controlled/hijacked stream could push an
arbitrarily large or never-ending body and force OpenClaw to buffer it whole.
extensions/inworld/tts.ts— the non-OK TTS error body viaawait response.text(), inlined verbatim into the thrown error message.extensions/inworld/tts.ts(listInworldVoices) — the non-OK voices errorbody, same unbounded shape.
extensions/inworld/tts.ts(listInworldVoices) — the voices listingbody via
await response.json(), which reads the entire body into memorybefore parsing, with no byte ceiling and regardless of
Content-Length.Why This Change Was Made
Each call site now reads through the shared bounded reader
readResponseWithLimit(fromopenclaw/plugin-sdk/response-limit-runtime, theexisting
@openclaw/media-corehelper), which cancels the underlying stream onoverflow and enforces an idle timeout:
MAX_AUDIO_BYTES * 2(32 MiB). The wire body isnewline-delimited JSON carrying base64 audio (~4/3 the decoded size) plus
a JSON envelope, so doubling the shared 16 MiB audio limit admits a full-size
legitimate clip while still bounding memory. A 30s idle timeout aborts a
stalled stream so a hung socket cannot be pinned open.
MAX_AUDIO_BYTES(16 MiB) — generous for asmall catalog — closing the previously unbounded
response.json().readInworldErrorBodySnippet(8 KiB cap, 400-char collapse). On overflow itreturns a fixed marker rather than echoing attacker-controlled bytes into the
thrown message, so a hostile error body can neither be buffered whole nor
inlined.
Design boundary / blast radius: this is intentionally single-extension and
self-contained. It touches only
extensions/inworld/tts.tsand its colocatedtest, adds no new shared plugin-SDK surface, and edits no shared infra
(no
response-limit-runtimere-export change, no SDK-surface budget bump) — itdepends solely on helpers already exported on
main. The local error-snippetreader deliberately avoids introducing a new shared export. This is the Inworld
companion to the
#95103/#95108/#96495response-limit campaign andreuses the same bounded reader rather than introducing a new abstraction.
User Impact
Operators running the Inworld TTS/voices integration are protected from a memory
exhaustion / hang vector on the Inworld code path: oversized or never-ending
responses (and oversized error bodies) are now bounded, the stream is cancelled
to release the socket, and a stalled upstream is aborted. There is no change to
normal behavior — valid audio payloads, voice listings, and small error
messages parse and surface exactly as before.
Evidence
Real behavior proof (local scratchpad script, not committed). A real
node:httploopback CONNECT proxy routeshttp://example.com(viaHTTP_PROXY, so undici dispatches through it) to a local fake server thatstreams oversized bodies with no
Content-Length. The real exportedinworldTTS/listInworldVoicesdrive the realfetchWithSsrFGuard/ undicipath,
readResponseWithLimit, and a real TCP socket — no in-processReadableStreamfixture.Command:
node --import tsx scratchpad/inworld-http-proof.mtsFocused regression suite (15 existing + 11 added):
Command:
node scripts/run-vitest.mjs extensions/inworld/tts.test.tsAdded coverage: fail-closed (oversized audio / oversized voices JSON / oversized
error body), happy-path (multi-line NDJSON audio + voices list unchanged),
under-cap boundary (~1 MiB audio read intact), and regressions (malformed NDJSON
line, malformed voices JSON).
Mutation check (tests are load-bearing): temporarily widening
INWORLD_ERROR_BODY_MAX_BYTESto 1 MiB makes the oversized-error-body test fail(
1 failed | 25 passed); reverting returns to26 passed.oxlintandtsc --noEmitare clean on the changed files.Label: security
AI-assisted: implemented and verified with Claude Code (Anthropic) assistance —
the proof commands and the focused/mutation test runs above were executed for
real against the actual code path; a human reviewed the change before posting.