Skip to content

fix(inworld): bound TTS audio, voices, and error response reads to prevent OOM#95416

Merged
vincentkoc merged 3 commits into
openclaw:mainfrom
Alix-007:fix/bound-inworld-tts
Jun 29, 2026
Merged

fix(inworld): bound TTS audio, voices, and error response reads to prevent OOM#95416
vincentkoc merged 3 commits into
openclaw:mainfrom
Alix-007:fix/bound-inworld-tts

Conversation

@Alix-007

@Alix-007 Alix-007 commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

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
baseUrl override) 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 via
    await response.text(), before splitting newline-delimited JSON and
    concatenating 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 via
    await response.text(), inlined verbatim into the thrown error message.
  • extensions/inworld/tts.ts (listInworldVoices) — the non-OK voices error
    body, same unbounded shape.
  • extensions/inworld/tts.ts (listInworldVoices) — the voices listing
    body via await response.json(), which reads the entire body into memory
    before 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 (from openclaw/plugin-sdk/response-limit-runtime, the
existing @openclaw/media-core helper), which cancels the underlying stream on
overflow and enforces an idle timeout:

  • Main audio body: capped at MAX_AUDIO_BYTES * 2 (32 MiB). The wire body is
    newline-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.
  • Voices listing JSON: capped at MAX_AUDIO_BYTES (16 MiB) — generous for a
    small catalog — closing the previously unbounded response.json().
  • Error bodies (TTS + voices): read through a small, local
    readInworldErrorBodySnippet (8 KiB cap, 400-char collapse). On overflow it
    returns 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.ts and its colocated
test, adds no new shared plugin-SDK surface, and edits no shared infra
(no response-limit-runtime re-export change, no SDK-surface budget bump) — it
depends solely on helpers already exported on main. The local error-snippet
reader deliberately avoids introducing a new shared export. This is the Inworld
companion to the #95103 / #95108 / #96495 response-limit campaign and
reuses 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:http loopback CONNECT proxy routes http://example.com (via
HTTP_PROXY, so undici dispatches through it) to a local fake server that
streams oversized bodies with no Content-Length. The real exported
inworldTTS / listInworldVoices drive the real fetchWithSsrFGuard / undici
path, readResponseWithLimit, and a real TCP socket — no in-process
ReadableStream fixture.

Command: node --import tsx scratchpad/inworld-http-proof.mts

[proof] real node:http loopback CONNECT proxy, target=http://example.com, audioCap=33554432 voicesCap=16777216 errorCap=8192 bytes, wouldStream=134217728 bytes
PASS  oversized Inworld TTS audio stream throws bounded error :: threw=true msg="Inworld TTS audio stream too large: 33619968 bytes (limit: 33554432 bytes)"
PASS  audio stream cancelled: proxy saw socket abort before full body :: aborted=true bytesSent=35651584 (<134217728) chunks=34
PASS  negative control: raw unbounded read buffers the FULL body past the cap (proves the cap is load-bearing) :: unbounded buffered=134217728 vs bounded sent≈35651584
PASS  oversized Inworld voices JSON throws bounded error + cancels stream :: threw=true bytesSent=18874368 (<134217728) cap=16777216
PASS  oversized error body stays bounded and cancels stream :: messageLength=78 aborted=true bytesSent=1048576 (<33554432) cap=8192
PASS  happy path: small NDJSON audio payload still parses unchanged :: audio="real-audio-bytes" bytes=16
PASS  happy path: small voices JSON list still parses unchanged :: voices=[{"id":"Sarah","name":"Sarah","locale":"en-US"}]

[proof] 7 PASS, 0 FAIL
[proof] ALL PASS

Focused regression suite (15 existing + 11 added):

Command: node scripts/run-vitest.mjs extensions/inworld/tts.test.ts

 Test Files  1 passed (1)
      Tests  26 passed (26)
[test] passed 1 Vitest shard in 23.25s

Added 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_BYTES to 1 MiB makes the oversized-error-body test fail
(1 failed | 25 passed); reverting returns to 26 passed.

oxlint and tsc --noEmit are 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.

@openclaw-barnacle openclaw-barnacle Bot added extensions: inworld Extension: inworld size: S triage: needs-pr-context Candidate: external PR body lacks required problem context or evidence. labels Jun 20, 2026
@clawsweeper

clawsweeper Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed June 28, 2026, 8:02 PM ET / 00:02 UTC.

Summary
The PR changes extensions/inworld/tts.ts to cap Inworld TTS audio, decoded audio, voice-list, and error response reads, with new regression coverage in extensions/inworld/tts.test.ts.

PR surface: Source +73, Tests +125. Total +198 across 2 files.

Reproducibility: yes. by source inspection: current main and v2026.6.10 still use unbounded response.text() and response.json() reads on the Inworld TTS and voices paths. I did not execute an OOM repro in this read-only review, but the PR body includes terminal proof for the fixed path and a negative control.

Review metrics: 2 noteworthy metrics.

  • Inworld response caps: 5 enforced. The PR adds caps for TTS wire body, decoded audio, voices JSON, TTS error body, and voices error body, which are the compatibility-sensitive runtime behavior changes maintainers must accept.
  • Public SDK surface: 0 added, 0 changed. The diff keeps the fix local to the Inworld plugin and uses existing plugin SDK exports, avoiding a new third-party plugin API contract.

Stored data model
Persistent data-model change detected: serialized state: extensions/inworld/tts.test.ts. Confirm migration or upgrade compatibility proof before merge.

Root-cause cluster
Relationship: canonical
Canonical: #95416
Summary: This PR is the active Inworld-specific bounded-response fix; related merged PRs cover the same unbounded-response-read pattern in other surfaces but do not supersede this work.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge readiness
Overall: 🦞 diamond lobster
Proof: 🦞 diamond lobster
Patch quality: 🦞 diamond lobster
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Risk before merge

  • [P1] The new 32 MiB TTS wire-body cap, 16 MiB decoded-audio cap, 16 MiB voices cap, 8 KiB error-body cap, and idle timeouts intentionally fail closed where existing self-hosted or unusual Inworld endpoints previously buffered to completion.
  • [P1] The latest head is mergeable but currently unstable because required GitHub checks are still pending after the most recent commit.

Maintainer options:

  1. Accept the fail-closed caps (recommended)
    Maintainers can approve the Inworld-specific byte and idle-time ceilings as the intended availability and security tradeoff, then proceed once required checks pass.
  2. Tune limits before merge
    If legitimate Inworld deployments need larger responses, adjust the constants and tests while preserving bounded reads, decoded audio enforcement, and stream cancellation.
  3. Pause for provider policy
    If OpenClaw should not define provider-specific response-size contracts in this PR, keep it open until that policy is decided.

Next step before merge

  • [P1] Human maintainer review is needed to accept the plugin-specific fail-closed caps and wait for latest-head checks; there is no narrow automated repair to queue.

Security
Cleared: The diff reduces unbounded externally controlled response buffering and does not add dependencies, workflows, lockfiles, package resolution changes, secret handling, or new code-execution surfaces.

Review details

Best 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 v2026.6.10 still use unbounded response.text() and response.json() reads on the Inworld TTS and voices paths. I did not execute an OOM repro in this read-only review, but the PR body includes terminal proof for the fixed path and a negative control.

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 changes

Label justifications:

  • P2: This is focused memory-safety and availability hardening for one bundled speech plugin with limited blast radius.
  • merge-risk: 🚨 compatibility: The PR introduces hard Inworld response-size and idle-time limits that can reject or truncate responses that existing installations previously attempted to buffer.
  • rating: 🦞 diamond lobster: Overall readiness is 🦞 diamond lobster; proof is 🦞 diamond lobster and patch quality is 🦞 diamond lobster.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR body includes terminal proof from a real node:http loopback CONNECT proxy showing bounded reads, stream cancellation, a negative control, and unchanged happy paths through the exported Inworld functions; the latest decoded-audio cap is also covered by focused test diff.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes terminal proof from a real node:http loopback CONNECT proxy showing bounded reads, stream cancellation, a negative control, and unchanged happy paths through the exported Inworld functions; the latest decoded-audio cap is also covered by focused test diff.
Evidence reviewed

PR surface:

Source +73, Tests +125. Total +198 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 77 4 +73
Tests 1 125 0 +125
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 202 4 +198

What I checked:

  • Repository policy applied: Read the root and scoped extension policies; the plugin-boundary, fail-closed compatibility-risk, and best-fix review guidance apply because this PR changes a bundled plugin runtime path. (AGENTS.md:1, 305fa9c4ddbe)
  • Current main still has the bug shape: Current main reads Inworld TTS error/success bodies with response.text() and voices error/success bodies with response.text()/response.json(), so the central problem is not already fixed on main. (extensions/inworld/tts.ts:93, 305fa9c4ddbe)
  • Latest release still ships the unbounded reads: The latest release tag v2026.6.10 contains the same Inworld response.text() and response.json() paths, so the fix is not already shipped. (extensions/inworld/tts.ts:93, aa69b12d0086)
  • PR head bounds targeted reads: Current PR head a3acea84d4f15dfbdf788ba79b6a6dde014164e0 adds a 32 MiB TTS wire-body cap, a 16 MiB decoded-audio cap, a 16 MiB voices cap, 8 KiB error-body snippets, and idle timeouts. (extensions/inworld/tts.ts:153, a3acea84d4f1)
  • Regression coverage added: The PR adds tests for oversized TTS wire streams, decoded audio over the shared cap, oversized voices JSON, oversized error snippets, happy paths, and malformed under-cap payloads. (extensions/inworld/tts.test.ts:372, a3acea84d4f1)
  • Shared helper contract supports the fix: readResponseWithLimit reads response streams under a byte cap, cancels on overflow, supports idle-timeout cancellation, and throws caller-provided overflow errors. (packages/media-core/src/read-response-with-limit.ts:129, 305fa9c4ddbe)

Likely related people:

  • cshape: Authored the merged Inworld provider PR that added extensions/inworld/tts.ts, its tests, and the original TTS/voices response-read shape now being hardened. (role: feature introducer; confidence: high; commits: 0bcb4c95c185; files: extensions/inworld/tts.ts, extensions/inworld/tts.test.ts, extensions/inworld/speech-provider.ts)
  • steipete: Merged the original Inworld provider work and authored/refactored response-limit and Inworld hardening commits that are adjacent to this PR's helper choice. (role: merger and SDK-adjacent owner; confidence: high; commits: 0bcb4c95c185, 53d007bc878c, a49957ad76a0; files: extensions/inworld/tts.ts, src/plugin-sdk/response-limit-runtime.ts, packages/media-core/src/read-response-with-limit.ts)
  • vincentkoc: Authored merged response-limit hardening PRs for gateway pricing and Anthropic streams, and added the latest decoded-audio cap commit on this PR branch. (role: adjacent hardening contributor; confidence: medium; commits: b073d7cc11dc, d6cefe26f499, a3acea84d4f1; files: src/gateway/model-pricing-cache.ts, src/agents/anthropic-transport-stream.ts, extensions/inworld/tts.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

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 keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. labels Jun 20, 2026
@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation and removed triage: needs-pr-context Candidate: external PR body lacks required problem context or evidence. labels Jun 20, 2026
@Alix-007

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review — Addressed both P1s: (1) refreshed docs/.generated/plugin-sdk-api-baseline.sha256 for the new readResponseTextSnippet SDK export (the regenerated hash matches upstream/main's current baseline exactly), and (2) restructured the PR body with the preferred 'What Problem This Solves' and 'Evidence' sections. Vitest (19 tests) pass.

@clawsweeper

clawsweeper Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@clawsweeper clawsweeper Bot added status: 🔁 re-review loop A fresh ClawSweeper review was explicitly requested after the latest review. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: 🔁 re-review loop A fresh ClawSweeper review was explicitly requested after the latest review. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Jun 20, 2026
@Alix-007

Copy link
Copy Markdown
Contributor Author

CI note after re-checking the failing shards: the visible checks-node-core-fast failure is in src/node-host/runner.credentials.test.ts around remote SecretRef resolution, and the checks-node-core-tooling failure is in repo-wide tooling tests. Neither failure is in the Inworld TTS bounded-body code path touched by this PR.

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.

@clawsweeper clawsweeper Bot added status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Jun 22, 2026
@Alix-007
Alix-007 force-pushed the fix/bound-inworld-tts branch from 25beec3 to 75308ee Compare June 23, 2026 07:45
@openclaw-barnacle openclaw-barnacle Bot removed the docs Improvements or additions to documentation label Jun 23, 2026
@Alix-007

Copy link
Copy Markdown
Contributor Author

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].

@openclaw-barnacle openclaw-barnacle Bot added the scripts Repository scripts label Jun 23, 2026
@Alix-007

Copy link
Copy Markdown
Contributor Author

Follow-up CI fix: the remaining tooling failure was in plugin-sdk-surface-report.test.ts. Adding readResponseTextSnippet increases the public plugin SDK export count by one, so I updated the explicit surface export budget from 10349 to 10350.

Verified locally with:
node --max-old-space-size=8192 scripts/plugin-sdk-surface-report.mjs --check

The new dependency-guard-detect failure is not code-related: GitHub returned 500 while listing PR files (diff is temporarily unavailable due to heavy server load). I am rerunning/monitoring that transient guard.

@Alix-007
Alix-007 force-pushed the fix/bound-inworld-tts branch from 1736362 to 6a1f212 Compare June 23, 2026 08:18
@Alix-007

Copy link
Copy Markdown
Contributor Author

Maintenance update: pushed a non-force follow-up commit to align the plugin SDK surface budgets with current main (10386 public exports / 5212 public function exports). This targets the only merge conflict GitHub reported against scripts/plugin-sdk-surface-report.mjs.

Verified locally:

  • node --max-old-space-size=8192 scripts/plugin-sdk-surface-report.mjs --check
  • git diff --check

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@Alix-007

Copy link
Copy Markdown
Contributor Author

Merged latest upstream main into this branch to clear the dirty merge state. The only conflict was the plugin SDK surface budget; I aligned it with the current merged surface and verified the budget check.

Verified current head locally:

  • node --max-old-space-size=8192 scripts/plugin-sdk-surface-report.mjs --check
  • git diff --check

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@Alix-007
Alix-007 force-pushed the fix/bound-inworld-tts branch from 0d93157 to 50005fc Compare June 27, 2026 00:16
@Alix-007
Alix-007 force-pushed the fix/bound-inworld-tts branch from 50005fc to dde7141 Compare June 28, 2026 23:13
@openclaw-barnacle openclaw-barnacle Bot added size: M and removed scripts Repository scripts size: S labels Jun 28, 2026
@Alix-007 Alix-007 changed the title fix(inworld): bound TTS audio and error response body reads to prevent OOM fix(inworld): bound TTS audio, voices, and error response reads to prevent OOM Jun 28, 2026
@clawsweeper clawsweeper Bot added rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. labels Jun 28, 2026
@vincentkoc

Copy link
Copy Markdown
Member

Pushed a small maintainer follow-up in a3acea84d4f15dfbdf788ba79b6a6dde014164e0 to enforce the shared decoded audio cap after parsing the bounded Inworld NDJSON stream. The existing wire-body cap prevents unbounded buffering; this closes the remaining base64-expansion gap so decoded audio cannot exceed MAX_AUDIO_BYTES.

Focused validation passed locally:

node scripts/test-projects-serial.mjs extensions/inworld/tts.test.ts
# 1 file passed, 27 tests passed

Leaving this open for the fresh current-head checks to finish before merge.

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

Labels

extensions: inworld Extension: inworld merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. size: M status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants