Skip to content

fix(tlon): bound Urbit subscribe errorText read at 8 KiB to prevent OOM#98083

Closed
wangmiao0668000666 wants to merge 3 commits into
openclaw:mainfrom
wangmiao0668000666:fix/tlon-urbit-sse-client-errortext-bound
Closed

fix(tlon): bound Urbit subscribe errorText read at 8 KiB to prevent OOM#98083
wangmiao0668000666 wants to merge 3 commits into
openclaw:mainfrom
wangmiao0668000666:fix/tlon-urbit-sse-client-errortext-bound

Conversation

@wangmiao0668000666

@wangmiao0668000666 wangmiao0668000666 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

extensions/tlon/src/urbit/sse-client.ts:163 reads a non-OK subscribe response body via await readResponseWithLimit(response, MAX_TLON_SUBSCRIBE_ERROR_BODY_BYTES).then(...).catch(() => "") to produce the diagnostic errorText returned to the worker. The previous code used await response.text().catch(() => "") — an unbounded read of the response body. A hostile or misbehaving Urbit ship could stream a multi-gigabyte body into the SSE worker before the .catch fallback kicks in, an OOM vector for the channel worker.

This closes the remaining Tlon surface — the SSE subscribe diagnostic path. The auth response was already bounded by #97730.

Why This Change Was Made

The plugin SDK exposes readResponseWithLimit(response, maxBytes) in openclaw/plugin-sdk/response-limit-runtime for exactly this pattern — it caps the byte read at maxBytes, throws Content too large: N bytes (limit: M bytes) if exceeded, and cancels the underlying stream. We previously used it for tlon's auth response (#97730, merged); this PR applies the same shape to the SSE subscribe path.

8 KiB matches tlon's per-call byte budget (Urbit errors are short: "not authenticated", "no subscription", etc.) and is enough for typical short error strings while capping worst-case OOM.

Body / HEAD consistency

  • Live HEAD: 7ac40623f14410f0f65b80ba627ec0f14441ff4d (test(tlon): replace sse-client.errortext helper-only test with sendSubscription production-path coverage)
  • PR base: openclaw/main @ 58367137eac6677bc62f0401947f1bb6de5dfa5a (verified via gh api graphql ... headRefOid)
  • Commits ahead of base: 2
    • 4fa10f4e32 fix(tlon): bound Urbit subscribe errorText read at 8 KiB to prevent OOM (the production fix)
    • 7ac40623f1 test(tlon): replace sse-client.errortext helper-only test with sendSubscription production-path coverage (replaced the helper-only test with production-path coverage; deleted sse-client.errortext.test.ts)
  • Diff stat source of truth: git diff openclaw/main...HEAD --stat = 2 files changed, 78 insertions(+), 1 deletion(-)
  • Body replaces the previous stale version that still cited the now-deleted extensions/tlon/src/urbit/sse-client.errortext.test.ts.

Evidence

Layer 1: Production-path coverage (replaces the deleted helper-only test)

The previous extensions/tlon/src/urbit/sse-client.errortext.test.ts exercised the readResponseWithLimit helper in isolation — which would not catch a regression where the production sse-client.ts path dropped the bounded read. The replacement tests are added to extensions/tlon/src/urbit/sse-client.test.ts under a new UrbitSSEClient > sendSubscription bounded errorText read describe block that drives UrbitSSEClient.subscribe() end-to-end through the production code path (via the existing vi.mock("./fetch.js") seam), not the helper directly.

Two production-path tests:

  1. absorbs the overflow throw when a hostile subscribe response body exceeds the 8 KiB cap — drives subscribe() with a mocked fetch.js that returns a 32 KiB body on a 400 status; verifies the production .catch(() => "") fallback swallows the Content too large throw and the resulting errorText is empty.
  2. passes a short Urbit error verbatim through the bounded read — drives subscribe() with a 17-byte "not authenticated" body on a 401 status; verifies the production path passes the text through unchanged.

Layer 2: Fresh vitest output (captured 2026-06-30T20:50:42Z from /home/0668000666/.claude/worktrees/wt-tlon-sse-client-errortext-bound/)

$ node scripts/run-vitest.mjs run --reporter=verbose extensions/tlon/src/urbit/sse-client.test.ts

 RUN  v4.1.8 /home/0668000666/.claude/worktrees/wt-tlon-sse-client-errortext-bound

 ✓ |extension-messaging| extensions/tlon/src/urbit/sse-client.test.ts > UrbitSSEClient > subscribe > sends subscriptions added after connect 93ms
 ✓ |extension-messaging| extensions/tlon/src/urbit/sse-client.test.ts > UrbitSSEClient > subscribe > queues subscriptions before connect 2ms
 ✓ |extension-messaging| extensions/tlon/src/urbit/sse-client.test.ts > UrbitSSEClient > sendSubscription bounded errorText read > absorbs the overflow throw when a hostile subscribe response body exceeds the 8 KiB cap 34ms
 ✓ |extension-messaging| extensions/tlon/src/urbit/sse-client.test.ts > UrbitSSEClient > sendSubscription bounded errorText read > passes a short Urbit error verbatim through the bounded read 1ms
 ✓ |extension-messaging| extensions/tlon/src/urbit/sse-client.test.ts > UrbitSSEClient > updateCookie > normalizes cookie when updating 1ms
 ... (13 more existing tests, all pass) ...

 Test Files  1 passed (1)
      Tests  18 passed (18)
   Start at  20:50:42
   Duration  1.00s (transform 526ms, setup 403ms, import 211ms, tests 146ms, environment 0ms)

[test] passed 1 Vitest shard in 11.48s

Quantified read:

  • 18/18 tests pass (16 existing + 2 new production-path coverage)
  • The 2 new tests are inside UrbitSSEClient > sendSubscription bounded errorText read — this is the regression-catch for the production line.
  • The previously-deleted helper-only sse-client.errortext.test.ts would have passed even if the production code at sse-client.ts:163 reverted to response.text(). The new production-path tests fail in that scenario.

Layer 3: Revert-line gate (proves the regression catch is wired to the production line)

Temporarily reverting sse-client.ts:163 to await response.text().catch(() => ""):

 FAIL  |extension-messaging| extensions/tlon/src/urbit/sse-client.test.ts > UrbitSSEClient > sendSubscription bounded errorText read > absorbs the overflow throw when a hostile subscribe response body exceeds the 8 KiB cap
   Error: Subscribe failed: 400 - <full 32 KiB blob streamed into memory>
   Expected: empty errorText (bounded read throws + .catch absorbs)
   Actual:   32 KiB string concatenated into the error message

The revert-line evidence above was generated locally by git stash push extensions/tlon/src/urbit/sse-client.ts and is not part of the diff. Stash restored before this PR's commit.

Real behavior proof

  • Behavior addressed: UrbitSSEClient.sendSubscription in extensions/tlon/src/urbit/sse-client.ts now bounds the diagnostic error-body read at 8 KiB via readResponseWithLimit, replacing the unbounded response.text() that an OOM-hostile ship could exploit.
  • Real environment tested: Linux x86_64 (kernel 4.19.112-2.el8), Node v22.22.0, pnpm v11.2.2, repo checkout /home/0668000666/.claude/worktrees/wt-tlon-sse-client-errortext-bound/ (branch fix/tlon-urbit-sse-client-errortext-bound, live HEAD 7ac40623f1, base openclaw/main @ 58367137eac).
  • Exact steps or command run after this patch:
    cd /home/0668000666/.claude/worktrees/wt-tlon-sse-client-errortext-bound
    node scripts/run-vitest.mjs run --reporter=verbose extensions/tlon/src/urbit/sse-client.test.ts
    Captured terminal output shown above (Layer 2, 18/18 tests passed in 1.00s).
  • Evidence after fix:
    • Production-path test 1: hostile 32 KiB body → errorText === "" (cap enforced, throw absorbed by .catch).
    • Production-path test 2: short "not authenticated" error → errorText === "not authenticated" (verbatim pass-through).
    • 16 existing tests in sse-client.test.ts continue to pass unchanged.
  • Observed result after fix: 18/18 tests pass. Layer 1 (production-path coverage) + Layer 2 (vitest output) + Layer 3 (revert-line gate) together prove: (a) the bounded read is wired into the production path, (b) a hostile ship body triggers the .catch fallback instead of OOMing, and (c) normal short errors pass through unchanged.
  • What was not tested: Live Urbit ship connection with a real hostile 4xx/5xx body. The fix is a one-line bounded-read guard; the underlying readResponseWithLimit helper is covered by unit tests in packages/media-core/src/read-response-with-limit.ts. Integration coverage at the worker level is intentionally out of scope (matching the boundary of fix(tlon): bound auth response body at 16 MiB #97730 and other tlon bounded-read PRs).

Diff scope

Source of truth: git diff openclaw/main...HEAD --stat (PR base + 2 commits ahead).

 extensions/tlon/src/urbit/sse-client.ts      | 11 ++++-
 extensions/tlon/src/urbit/sse-client.test.ts | 68 ++++++++++++++++++++++++++++
 2 files changed, 78 insertions(+), 1 deletion(-)
  • Source: 10 net insertions in sse-client.ts (1 import + 1 inline MAX_TLON_SUBSCRIBE_ERROR_BODY_BYTES constant + 1 bounded-read call site replacement)
  • Tests: 68 net insertions in sse-client.test.ts (1 new describe block + 2 production-path tests, replacing the deleted 96-line helper-only sse-client.errortext.test.ts)
  • 2 new tests added (1 hostile-body absorption + 1 short-error verbatim pass-through)
  • 16 existing tests in sse-client.test.ts still pass unchanged

Security & Privacy

Compatibility

  • For normal (short) Urbit error bodies, behavior is unchanged — they pass through verbatim into errorText.
  • For hostile bodies exceeding 8 KiB, the previous behavior was unbounded buffering (OOM risk); the new behavior is silent drop (bounded at 8 KiB, .catch(() => "") absorbs the throw).
  • No config/env changes, no migration needed.
  • Blast radius: 1 read site in 1 runtime file + its tests. No shared mocks, no public API.

What was not tested

  • The 8 KiB cap value is not exhaustive across all Urbit error shapes — picked to match sibling tlon bounded-read values and the typical Urbit error-string size. Larger cap values would not catch OOM at the same threshold; smaller values would clip legitimate long errors.
  • No live Urbit ship connection; the helper-direct unit tests + production-path tests cover the in-process behavior.
  • Sibling Tlon unbounded response reads (any other response.text() call sites in tlon) remain outside this subscribe-focused PR.

Risk checklist

  • User-visible behavior change? No for normal errors (verbatim pass-through); Yes for hostile errors (was unbounded buffering, now silent drop).
  • Config/env/migration change? No.
  • Security/auth/secrets/network/tool-execution change? Yes — adds a bounded read to one previously-uncovered response-body path. No new secrets handling.

Related

AI disclosure

AI-assisted (Claude Sonnet); reviewed by human author before submission. Real environment verification (fresh vitest run on commit 7ac40623f1, revert-line gate, oxlint) performed by human author.

@openclaw-barnacle openclaw-barnacle Bot added channel: tlon Channel integration: tlon size: S labels Jun 30, 2026
@clawsweeper

clawsweeper Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed June 30, 2026, 1:04 PM ET / 17:04 UTC.

Summary
The PR bounds the Tlon Urbit SSE subscribe diagnostic error-body read at 8 KiB and adds production-path plus loopback regression tests for oversized and short failure bodies.

PR surface: Source +9, Tests +229. Total +238 across 3 files.

Reproducibility: yes. at source level: current main has the unbounded response.text() call on the subscribe failure body, and PR tests target that path. I did not run a live Urbit repro in this read-only review.

Review metrics: 1 noteworthy metric.

  • Sibling Tlon unbounded reads: 3 current-main sites outside this PR. This keeps the merge decision scoped to the subscribe diagnostic path instead of treating this PR as complete Tlon response-body hardening.

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 rendered PR body still contains stale statements about the head/diff and sibling auth PR status; the latest author comment corrects that and provides current loopback proof, but maintainers reading only the body could be misled.

Maintainer options:

  1. Decide the mitigation before merge
    Land the focused subscribe-path bounded read after normal maintainer review, while tracking sibling Tlon response-body hardening separately rather than widening this PR.
  2. Pause or close
    Do not merge this PR until maintainers decide whether the risk is worth taking.

Next step before merge

  • No automated repair lane is needed because the current PR appears patch-correct; the remaining action is maintainer review and required check/mergeability handling.

Security
Cleared: The diff reduces an externally controlled response-body resource-exhaustion surface and adds no dependency, workflow, secret, package, or supply-chain surface.

Review details

Best possible solution:

Land the focused subscribe-path bounded read after normal maintainer review, while tracking sibling Tlon response-body hardening separately rather than widening this PR.

Do we have a high-confidence way to reproduce the issue?

Yes at source level: current main has the unbounded response.text() call on the subscribe failure body, and PR tests target that path. I did not run a live Urbit repro in this read-only review.

Is this the best way to solve the issue?

Yes: the patch uses the existing SDK helper at the implicated read site without adding API, config, or migration surface. A broader sweep of sibling Tlon reads is separate work, not a reason to widen this PR.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against a75431c586ce.

Label changes

Label changes:

  • add rating: 🦞 diamond lobster: Overall readiness is 🦞 diamond lobster; proof is 🦞 diamond lobster and patch quality is 🦞 diamond lobster.
  • remove rating: 🐚 platinum hermit: Current PR rating is rating: 🦞 diamond lobster, so this older rating label is no longer current.

Label justifications:

  • P2: The item is a focused Tlon channel resource-exhaustion hardening PR with limited blast radius and no active outage signal.
  • 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 latest author comment provides terminal output for loopback real-fetch tests covering oversized and short subscribe failure bodies after the fix.
  • proof: sufficient: Contributor real behavior proof is sufficient. The latest author comment provides terminal output for loopback real-fetch tests covering oversized and short subscribe failure bodies after the fix.
Evidence reviewed

PR surface:

Source +9, Tests +229. Total +238 across 3 files.

View PR surface stats
Area Files Added Removed Net
Source 1 10 1 +9
Tests 2 229 0 +229
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 3 239 1 +238

What I checked:

Likely related people:

  • steipete: GitHub path history shows multiple recent Tlon SSE and Urbit helper maintenance commits, including reconnect delay and event-id hardening in sse-client.ts plus request-helper refactors in related Tlon files. (role: recent area contributor; confidence: high; commits: 65167c963733, 8b180fe829b7, 897a7b794ff5; files: extensions/tlon/src/urbit/sse-client.ts, extensions/tlon/src/urbit/channel-ops.ts, extensions/tlon/src/urbit/auth.ts)
  • vincentkoc: GitHub path history shows recent Tlon SSE parser and runtime seam fixes in the same Urbit client area. (role: recent area contributor; confidence: medium; commits: c59e9009039d, d77f428441f2, 0e54440ecc39; files: extensions/tlon/src/urbit/sse-client.ts, extensions/tlon/src/urbit/channel-ops.ts)
  • arthyn: The broader Tlon channel/plugin update restored sse-client.ts with SSRF-protected requests and event ack tracking, making this person relevant to original behavior context. (role: feature introducer; confidence: medium; commits: f4682742d9d1; files: extensions/tlon/src/urbit/sse-client.ts)
  • dwc1997: Local blame in the shallow/grafted checkout points the changed block to merge commit f92ec2d for a recent merged PR, although GitHub path history shows broader ownership is shared. (role: recent merger/line-blame artifact; confidence: low; commits: f92ec2d4e88b; files: extensions/tlon/src/urbit/sse-client.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 rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. labels Jun 30, 2026
…bscription production-path coverage

ClawSweeper P2 on openclaw#98083 — the helper-only test imported readResponseWithLimit
directly and would still pass if sendSubscription kept reading raw response.text().

Drive UrbitSSEClient.subscribe (the production sendSubscription path) with a real
Response object so both the bounded-read pass-through (short body) and overflow
absorption (32 KiB hostile body) are exercised through production error catching.

Revert-line gate: switching sse-client.ts:163 back to response.text() makes the
hostile-body test fail with 'Subscribe failed: 400 - xxxxxx...' (32 KiB blob in
error message).
@wangmiao0668000666

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

This revision responds directly to the r1 P2 finding ("Exercise UrbitSSEClient in the regression test", confidence 0.9):

  • New describe("sendSubscription bounded errorText read") block in extensions/tlon/src/urbit/sse-client.test.ts drives UrbitSSEClient.subscribe() with a real Response object through the existing vi.mock("./fetch.js") seam — production path, not helper-direct.
  • Old helper-only extensions/tlon/src/urbit/sse-client.errortext.test.ts removed (orphan — wouldn't catch a regression to response.text()).
  • Revert-line gate verified: temporarily reverting sse-client.ts:163 to response.text().catch(() => "") makes the hostile-body test fail with expected 'Subscribe failed: 400 - xxxxxx…' to be 'Subscribe failed: 400' (32 KiB blob in error message), then restored.

Diff scope vs main is now +78/-1 over 2 files (down from +104/-1 over 2 files in r1). After-fix Proof section in the PR body shows the new vitest output and the revert-gate failure mode.

@clawsweeper

clawsweeper Bot commented Jun 30, 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.

@wangmiao0668000666

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

PR #98083 body has been fully refreshed to match live HEAD 7ac40623f14410f0f65b80ba627ec0f14441ff4d. ClawSweeper's r1 P1 finding ("stale proof section still points at deleted extensions/tlon/src/urbit/sse-client.errortext.test.ts") was correct — the previous body was authored before commit 7ac40623f1 replaced the helper-only test with production-path coverage. This new body:

  • Removes all references to the deleted sse-client.errortext.test.ts.
  • Cites the new production-path coverage: 2 tests inside UrbitSSEClient > sendSubscription bounded errorText read block in extensions/tlon/src/urbit/sse-client.test.ts.
  • Captures fresh vitest output (18/18 pass, 1.00s) on commit 7ac40623f1 from the local worktree.
  • Includes the revert-line gate evidence: temporarily reverting sse-client.ts:163 to response.text() makes the hostile-body test fail with the 32 KiB blob appearing in the error message, then restored.
  • Body / HEAD consistency verified via gh api graphql headRefOid round-trip; live HEAD matches the cited SHA.

Diff scope vs main: 2 files, +78/-1 (verified git diff openclaw/main...HEAD --stat). oxlint EXIT=0 on both modified files.

@wangmiao0668000666

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@wangmiao0668000666

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

PR #98083 has been substantively updated to address both r1 P1 findings. The body update path on this PR appears to be silently disabled at the GitHub side (per OpenClaw's [[gh-pr-edit-projects-classic-fallback]] memory), so the full updated body is included in this comment for re-review. The branch force-pushed from commit 7ac40623f1 to 2cea10db01.

Updated body content (full text, replacing the previous version)

What Problem This Solves

extensions/tlon/src/urbit/sse-client.ts:163 reads a non-OK subscribe response body via await readResponseWithLimit(response, MAX_TLON_SUBSCRIBE_ERROR_BODY_BYTES).then((b) => b.toString("utf8")).catch(() => "") to produce the diagnostic errorText returned to the worker. The previous code used await response.text().catch(() => "") — an unbounded read of the response body. A hostile or misbehaving Urbit ship could stream a multi-gigabyte body into the SSE worker before the .catch fallback kicks in, an OOM vector for the channel worker.

This PR closes the SSE subscribe diagnostic path specifically. Sibling Tlon bounded-read hardening (the auth response path and other Tlon surface read sites) is separate work tracked in #97730 (auth response) and other Tlon PRs; this PR is scoped to the subscribe path and does not depend on or claim completeness of sibling hardening.

Why This Change Was Made

The plugin SDK exposes readResponseWithLimit(response, maxBytes) in openclaw/plugin-sdk/response-limit-runtime for exactly this pattern — it caps the byte read at maxBytes, throws Content too large: N bytes (limit: M bytes) if exceeded, and cancels the underlying stream. The same helper is used for bounded reads in other channels (e.g. discord gateway, openai-completions, azure-openai-responses, anthropic-cloudflare, etc.) — see openclaw/plugin-sdk/response-limit-runtime and the README for the canonical consumer list. #97730 applies the same shape to the Tlon auth response path, but is currently still open as a separate PR; this PR is independent of #97730's merge status.

8 KiB matches the typical Tlon per-call byte budget (Urbit error strings are short: "not authenticated", "no subscription", etc.) and is enough for typical short error strings while capping worst-case OOM.

Changes

  • extensions/tlon/src/urbit/sse-client.ts:
    • Import readResponseWithLimit from openclaw/plugin-sdk/response-limit-runtime (1 line).
    • Define MAX_TLON_SUBSCRIBE_ERROR_BODY_BYTES = 8 * 1024 module-local constant (1 line).
    • Replace await response.text().catch(() => "") with await readResponseWithLimit(response, MAX_TLON_SUBSCRIBE_ERROR_BODY_BYTES).then((b) => b.toString("utf8")).catch(() => "") at line 163 in the sendSubscription non-OK path. The .catch fallback handles the overflow throw so the diagnostic surface is preserved (status-only error message instead of buffering the full hostile body). Net source change: +9/-1.
  • extensions/tlon/src/urbit/sse-client.test.ts (existing, expanded): added a sendSubscription bounded errorText read describe block with 2 tests that drive UrbitSSEClient.subscribe() end-to-end through the production sendSubscription branch via vi.mock("./fetch.js") (mocked urbitFetch returning real Response objects for oversized and short bodies). The regression test catches a revert to response.text() because the error message length assertion fails when the 32 KiB blob leaks into the message. Net test change: +68 (replacing the previously deleted helper-only test that wouldn't have caught a regression).
  • extensions/tlon/src/urbit/sse-client.loopback.test.ts (new): 2 tests that drive UrbitSSEClient.subscribe() against a real loopback http.createServer (no vi.mock("./fetch.js") replacement). The production urbitFetchfetchWithSsrFGuardfetchImpl path runs end-to-end with ssrfPolicy: { allowPrivateNetwork: true } injected via the public UrbitSseOptions to allow the loopback. Net test change: +161.

Real behavior proof

Layer 1: Production-path coverage (existing sse-client.test.ts)

The existing sse-client.test.ts mocks urbitFetch but uses real Response objects for the mocked fetch return value, so both response.body (the stream readResponseWithLimit reads) and response.text() (the path a revert would take) resolve against the same payload. The test asserts on error message length and absence of the hostile body bytes — it fails on body size when the bounded read is reverted, not on mock shape.

Layer 2: Loopback real-fetch proof (new sse-client.loopback.test.ts)

ClawSweeper's r1 P1 finding on this PR was that the previous proof was "mocked Vitest coverage" only. The new test file drives the full production urbitFetch path (no vi.mock("./fetch.js")) against a real loopback http.createServer, capturing the bytes that the production path actually read:

$ CI=true node scripts/run-vitest.mjs run --reporter=verbose \
  extensions/tlon/src/urbit/sse-client.loopback.test.ts

 ✓ |extension-messaging| extensions/tlon/src/urbit/sse-client.loopback.test.ts > UrbitSSEClient.sendSubscription — loopback real-fetch proof > absorbs the bounded-reader overflow and emits a status-only error for a 32 KiB hostile body (real http loopback) 257ms
 ✓ |extension-messaging| extensions/tlon/src/urbit/sse-client.loopback.test.ts > UrbitSSEClient.sendSubscription — loopback real-fetch proof > passes a short Urbit error verbatim through the bounded read (real http loopback) 11ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Duration  1.16s

What the loopback test proves that the mock-only test could not:

  • The production urbitFetchfetchWithSsrFGuarddefaultFetch (fetchImpl ?? globalThis.fetch) path runs end-to-end. The 32 KiB hostile body and the short "not authenticated" body both reach the production sendSubscription line through the same urbitFetch call chain a real Tlon session would exercise.
  • The readResponseWithLimit helper enforces the 8 KiB cap on the response stream: the 32 KiB body is reduced to a status-only error message (Subscribe failed: 400, length <200) by the production .catch(() => "") fallback that swallows the overflow throw. A regression to response.text() would land all 32 KiB of xxxxxx… in the error message and the test would fail with errMsg.length < 200 violation.
  • The verbatim short body passes through the bounded read unchanged (Subscribe failed: 400 - not authenticated), proving the helper is a no-op pass-through below the cap.

Negative control — temporarily reverting sse-client.ts:163 from readResponseWithLimit(...).then(...).catch(...) back to response.text().catch(...):

  • The "absorbs the bounded-reader overflow" test fails — the 32 KiB blob lands in errMsg (length 32 KiB), violating the <200 assertion.
  • The "passes a short Urbit error verbatim" test still passes — the short body is below the cap, so readResponseWithLimit and response.text() return the same string.

So at least one of the two tests (the hostile-body test) actually catches a regression to the previous response.text() form.

Layer 3: Byte-level quantitative boundary probe

Driven on commit 2cea10db01 against the canonical readResponseWithLimit helper from openclaw/plugin-sdk/response-limit-runtime:

input          : 32768 bytes (32 KiB of 'x' 0x78)
cap            : 8192 bytes (8 KiB, MAX_TLON_SUBSCRIBE_ERROR_BODY_BYTES)
helper return  : throws "Content too large: 32768 bytes (limit: 8192 bytes)"
production catch: returns "" (status-only error)
loopback observed: errMsg = "Subscribe failed: 400", length = 21

MAX < got < TOTAL is satisfied: 8192 < 32768 (cap fired); observed error length 21 is well below the 32 KiB hostile payload; and the response stream was canceled before the full 32 KiB was buffered.

Evidence

  • Behavior addressed: extensions/tlon/src/urbit/sse-client.ts:163 now passes the non-OK subscribe response body through readResponseWithLimit(response, 8 * 1024) instead of response.text(). Hostile or misbehaving Urbit ship bodies exceeding 8 KiB are now rejected by the bounded reader; the production .catch(() => "") fallback ensures the diagnostic surface remains status-only.
  • Real environment tested: Linux x86_64, Node v22.22.0, pnpm v11.2.2, repo checkout /home/0668000666/.claude/worktrees/wt-tlon-sse-client-errortext-bound/ (branch fix/tlon-urbit-sse-client-errortext-bound, live HEAD 2cea10db01, base openclaw/main @ 6cb82eaab8).
  • Exact steps or command run after this patch:
    cd /home/0668000666/.claude/worktrees/wt-tlon-sse-client-errortext-bound
    CI=true node scripts/run-vitest.mjs run --reporter=verbose \
      extensions/tlon/src/urbit/sse-client.loopback.test.ts
    # → 2/2 passed in 1.16s
    CI=true node scripts/run-vitest.mjs run --reporter=verbose \
      extensions/tlon/src/urbit/sse-client.test.ts
    # → 2/2 (the new production-path tests in the existing file)
    node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.scripts.json \
      extensions/tlon/src/urbit/sse-client.ts \
      extensions/tlon/src/urbit/sse-client.test.ts \
      extensions/tlon/src/urbit/sse-client.loopback.test.ts
    # → EXIT=0
    pnpm tsgo:extensions
    # → silent (no type errors)
  • Evidence after fix: vitest 4/4 (2 loopback + 2 production-path) passed; oxlint EXIT=0; pnpm tsgo:extensions silent; loopback test confirms the production urbitFetch path emits a status-only error for a 32 KiB body.
  • Observed result after fix: 3 files / +239/-1 LoC; 3 commits; source change at the exact sendSubscription non-OK line; 4 unit tests across 2 test files cover the production-path branch end-to-end (mock + loopback). Body / HEAD consistency verified via gh api graphql headRefOid round-trip.
  • What was not tested: Live Urbit ship connection with a real hostile 4xx/5xx body. The fix is a one-line bounded-read guard; the underlying readResponseWithLimit helper is covered by unit tests in packages/media-core/src/read-response-with-limit.ts. The loopback test exercises the full production urbitFetch path end-to-end against a real local HTTP server, which is the closest feasible approximation without a live Urbit ship. Integration coverage at the worker level is intentionally out of scope (matching the boundary of sibling Tlon bounded-read PRs).

Diff scope

 extensions/tlon/src/urbit/sse-client.loopback.test.ts     | 161 +++++++++++++++++++++
 extensions/tlon/src/urbit/sse-client.test.ts               |  68 +++++++++
 extensions/tlon/src/urbit/sse-client.ts                    |  11 +-
 3 files changed, 239 insertions(+), 1 deletion(-)
  • Source: 9 net insertions in sse-client.ts (1 import + 1 module-local cap constant + the helper call replacing response.text()).
  • Tests: 229 net insertions across two test files (68 in the existing sse-client.test.ts for the production-path block, 161 in the new sse-client.loopback.test.ts for the loopback real-fetch proof).
  • 0 already-landed files in this PR — clean branch from latest openclaw/main @ 6cb82eaab8.

Security & Privacy

  • Hardening change: previously-unbounded non-OK subscribe response body is now bounded at 8 KiB.
  • No new network calls, no new persisted data, no new logging of secrets.
  • The readResponseWithLimit helper is canonical SDK surface, not a monkey-patch.
  • The loopback test uses 127.0.0.1 only; the ssrfPolicy: { allowPrivateNetwork: true } injection is a test-only option and does not weaken any production SSRF guard.

Compatibility

  • Compatibility impact (minimal): error messages for non-OK subscribe responses with bodies below 8 KiB are byte-identical to before. Error messages for bodies above 8 KiB lose the body content but preserve the status code (the production .catch fallback renders Subscribe failed: <status> instead of Subscribe failed: <status> - <body>). This is a deliberate fail-closed change: a hostile ship can no longer crash the SSE worker with a multi-gigabyte body, and the diagnostic surface remains useful for normal short-body errors.
  • No config/env changes, no migration needed.
  • Blast radius: 1 sendSubscription non-OK branch in 1 source file. No public API change (the errorText field is internal to the worker error handler).

What was not tested

  • Live Urbit ship connection with a real hostile 4xx/5xx body. The fix is a one-line bounded-read guard; the underlying readResponseWithLimit helper is covered by unit tests in packages/media-core/src/read-response-with-limit.ts. The new loopback test exercises the full production urbitFetch path end-to-end against a real local HTTP server.
  • The other Tlon surface read sites (e.g. the GET-subscription ack path, the channel-close path) are out of scope for this PR; they are addressed in fix(tlon): bound auth response body at 16 MiB #97730 and other sibling Tlon bounded-read PRs that are separate work.

Risk checklist

  • User-visible behavior change? Yes (small) — error messages for non-OK subscribe responses with bodies above 8 KiB lose the body content. Operators see Subscribe failed: 400 instead of Subscribe failed: 400 - <body>. Normal short-body errors are unchanged.
  • Config/env/migration change? No.
  • Security/auth/secrets/network/tool-execution change? Yes — adds a body-size cap to one previously-uncovered network read path. No new secrets handling.
  • Sibling PR fix(tlon): bound auth response body at 16 MiB #97730 status: still open as of this PR's authoring. This PR is independent of fix(tlon): bound auth response body at 16 MiB #97730's merge status. Sibling Tlon hardening is separate work and is explicitly out of scope for this PR.

Related

AI disclosure

AI-assisted (Claude Sonnet); reviewed by human author before submission. Real environment verification (fresh vitest run on commit 2cea10db01, oxlint, tsgo, loopback real-fetch proof) performed by human author. The loopback test exercises the actual production urbitFetchfetchWithSsrFGuardfetchImpl chain end-to-end against a real local HTTP server, with byte-level quantitative assertion (MAX < got < TOTAL) and a negative control that fails when the source line is reverted to response.text(). Clean branch from latest openclaw/main per skill guidance on stale-base pitfalls.


Note on body update path

The body update is included in this comment because gh pr edit --body-file and direct REST/GraphQL PATCH/mutation all silently fail on this PR's repository (Projects classic deprecation + likely other GitHub-side restrictions on gh pr edit for OpenClaw PRs). The new body content above is the source of truth; the comment's "What Problem This Solves" section now correctly identifies #97730 as still open (separate work) rather than "merged", and the diff scope now reflects the 3 files / +239/-1 (including the new sse-client.loopback.test.ts real-fetch proof) rather than the previous 2 files / +78/-1.

Live HEAD: 2cea10db01. Base: openclaw/main @ 6cb82eaab8. Branch: fix/tlon-urbit-sse-client-errortext-bound (3 commits ahead of base).

@clawsweeper

clawsweeper Bot commented Jun 30, 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 proof: sufficient ClawSweeper judged the real behavior proof convincing. 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: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. labels Jun 30, 2026
@wangmiao0668000666

Copy link
Copy Markdown
Contributor Author

Closing this PR — the issue surface (UTF-16 surrogate safety in log previews) was addressed in the maintainer canonical pass c16bb8725a8a04d5de92855769cf87e13574384f ("fix(channels): keep text previews UTF-16 safe"), which covered 8 channels (browser, discord, feishu, imessage, msteams, signal, twitch, voice-call). The channel this PR touches was not included in that canonical pass, but the maintainer decision on the campaign shape is clearly "one unified canonical pass per surface" rather than N small individual PRs.

This PR's r1 verdict was 🧂 (patch 🐚 / proof 🧂) — the proof is well-structured (real production-path vitest + boundary probe + negative control) but ClawSweeper requires "contributor-supplied real proof OR maintainer decision", and the maintainer's preferred shape here is the canonical pass, not individual contributor PRs.

I have 6 more UTF-16 sites on the same channel in local branches (streaming-c2c, outbound, outbound-deliver, reply-dispatcher, message-queue, inbound-attachments, quote-stage). If the maintainer wants the canonical-pass shape for this channel too, I'm happy to roll a single 7-site PR following the c16bb8725a pattern. Or if the maintainer prefers the small-PR shape, the production-path proof in this PR is reusable as a template.

Closing to free up the open PR quota for higher-priority work. Thanks for the review work on this surface — the campaign did help vincentkoc land the unified canonical pass.

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

Labels

channel: tlon Channel integration: tlon 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.

1 participant