fix(thread-ownership): bound 409 response read and preserve cancel semantics#98941
Conversation
|
Codex review: needs maintainer review before merge. Reviewed July 14, 2026, 6:41 AM ET / 10:41 UTC. Summary PR surface: Source +19, Tests +36. Total +55 across 3 files. Reproducibility: yes. from source: current main parses the 409 body before cancellation, and malformed JSON reaches the outer fail-open catch. The contributor also supplied exact-head real-socket proof for the repaired valid, malformed, and oversized paths, though no separate current-main runtime capture was provided. Review metrics: 1 noteworthy metric.
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
Maintainer decision needed
Security Review detailsBest possible solution: Land exactly one authoritative-409 repair: prefer this narrower branch if the mention-cache retention policy is not part of the same decision, or select the broader pull request only if maintainers also want its separately proven cache cap. Do we have a high-confidence way to reproduce the issue? Yes from source: current main parses the 409 body before cancellation, and malformed JSON reaches the outer fail-open catch. The contributor also supplied exact-head real-socket proof for the repaired valid, malformed, and oversized paths, though no separate current-main runtime capture was provided. Is this the best way to solve the issue? Yes. Reusing the existing bounded SDK JSON reader inside the authoritative 409 branch is the narrowest maintainable fix and preserves the feature’s original cancellation contract; the only unresolved question is whether maintainers also want the competing PR’s separate cache cap. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 09e5d60d3c5c. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +19, Tests +36. Total +55 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
|
|
@clawsweeper re-review Addressed all P1 feedback:
|
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
@clawsweeper re-review Addressed all three P1 items:
|
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
@clawsweeper re-review Updated PR body with explicit per-site proof (current-head commit 162c068):
|
|
@clawsweeper re-review Updated body with real per-function proof:
|
|
@clawsweeper re-review Replaced mock-only Vitest proof with real local-forwarder runtime verification: a real HTTP server stands in for the Slack forwarder and the real exported plugin entrypoint (register + message_sending hook) is driven over real sockets (globalThis.fetch NOT mocked). All 3 cases PASS (well-formed/malformed/oversized 409 -> cancel=true). See updated Real behavior proof (commit 47c0395). |
|
🦞🧹 I asked ClawSweeper to review this item again. |
…mantics The thread-ownership 409 conflict path still used unbounded resp.json(), inconsistent with the bounded readers shipped across the other channel/provider success paths. A compromised or buggy forwarder returning a multi-GB 409 body would buffer the entire body before JSON.parse, OOMing the agent. Bound the 409 body via readResponseTextLimited (8 KiB cap; a 409 owner field is ~30 bytes). Wrap the parse in try/catch so a truncated or malformed 409 body still cancels the send (owner logged as "unknown") — the 409 status itself means another agent owns this thread, independent of body parseability. The discord webhook and huggingface /v1/models success reads from the original PR were superseded upstream by openclaw#98098 and openclaw#101079 (readProviderJsonResponse), so this branch now carries only the thread-ownership fix. Co-Authored-By: Claude <[email protected]>
47c0395 to
afa0f8f
Compare
|
@clawsweeper re-review Exact-head refresh at
This intentionally does not copy unrelated mention-cache hardening from #101968. Please re-review and re-rate this exact head, including whether this branch can stand independently rather than remain superseded. |
|
🦞👀 Command router queued. I will update this comment with the next step. |
|
Merged via squash.
|
…mantics (openclaw#98941) * fix(thread-ownership): bound 409 response read and preserve cancel semantics The thread-ownership 409 conflict path still used unbounded resp.json(), inconsistent with the bounded readers shipped across the other channel/provider success paths. A compromised or buggy forwarder returning a multi-GB 409 body would buffer the entire body before JSON.parse, OOMing the agent. Bound the 409 body via readResponseTextLimited (8 KiB cap; a 409 owner field is ~30 bytes). Wrap the parse in try/catch so a truncated or malformed 409 body still cancels the send (owner logged as "unknown") — the 409 status itself means another agent owns this thread, independent of body parseability. The discord webhook and huggingface /v1/models success reads from the original PR were superseded upstream by openclaw#98098 and openclaw#101079 (readProviderJsonResponse), so this branch now carries only the thread-ownership fix. Co-Authored-By: Claude <[email protected]> * fix(thread-ownership): bound forwarder conflict handling --------- Co-authored-by: Claude <[email protected]>
What Problem This Solves
The thread-ownership plugin's 409 conflict path still used an unbounded
resp.json(), inconsistent with the bounded readers shipped across the other channel/provider success paths (discord webhook, huggingface/v1/models, and others already usereadProviderJsonResponse/readResponseTextLimited). A compromised or buggy forwarder returning a multi-GB 409 body would buffer the entire body beforeJSON.parseeven runs, OOMing the agent.Why This Change Was Made
Same partial-hardening pattern already shipped across comfy, anthropic-oauth, sms, matrix, telegram, and discord/api.ts. The thread-ownership 409 path was the remaining outlier: its success/409 body read was unbounded and reached
JSON.parseover an internal forwarder whose response size is not guaranteed.A 409 from the forwarder means another agent owns the thread — the send must be cancelled regardless of whether the body parses. The fix preserves that cancel semantics even when the body is truncated or malformed.
User Impact
Well-formed 409 responses unchanged. A huge/non-JSON/truncated 409 body is capped (8 KiB) and the send is still cancelled (owner logged as
unknown), instead of OOMing the agent or crashing the ownership check. The prior behavior on a truncated 409 body was an unhandledSyntaxErrorfromJSON.parsethat fell into the outer catch and allowed the send — this fix makes a malformed 409 cancel correctly.Real behavior proof
Behavior addressed: Unbounded thread-ownership 409 body read; OOM risk + lost cancel semantics on a truncated/malformed 409.
Real environment tested: Linux x86_64, Node v24.14.0, commit
47c03956ddon branchfix/unbounded-success-json-reads(rebased onto currentmain).Exact steps or command run after this patch:
node --import tsx verify-thread-ownership-real.mjs— stands up a REAL local HTTP server as the Slack forwarder and drives the REAL exported plugin entrypoint (register+ themessage_sendinghook) fromextensions/thread-ownership/index.ts.globalThis.fetchis NOT mocked: the productionfetchWithSsrFGuardhandles each request over a real socket (each case's local server confirms it served the 409). The SSRF guard is told to allow private-network access, exactly like prod.Evidence after fix: Terminal capture of
noderuntime verification (live stdout, copied verbatim — non-mocked, real forwarder + real plugin entrypoint):Observed result after fix: Against a real local forwarder, the real
message_sendinghook (driven throughregister) returns{ cancel: true }for all three 409 shapes. A well-formed 409 cancels with the parsed owner (other-agent); a malformed/truncated 409 and a 64 KiB oversized 409 both cancel with ownerunknown— proving the body is capped (the oversized body is truncated at the 8 KiB cap, soJSON.parsefails and the 409 status alone drives cancellation) without unbounded buffering or a crash.What was not tested: A live multi-GB response from a production forwarder. The proof drives the real production entrypoint over a real socket; the oversized case uses a 64 KiB body (8x the cap) to exercise the truncation path deterministically.
Tests and validation
Risk checklist
unknown) instead of throwing into the outer catch and allowing it; well-formed 409 unchanged.readResponseTextLimitedalready used elsewhere.owner=unknown— but a 409 owner field is tiny in practice, and the cancel behavior is the correct 409 semantics, so this is strictly safer than the prior crash/allow.Closes: N/A (no existing issue)
Exact-head refresh
Rebased onto
upstream/main07284af7489d2b7a1e0a69b738968b707e21f0fb; current head isafa0f8fe32f61e493ba96c48082579f7d6f15c76.The 409 path now uses the existing
readProviderJsonResponseSDK contract with a 64 KiB cap rather than a local text-read/JSON.parsepair. A 409 remains authoritative: malformed or oversized response bodies log a bounded diagnostic and still return{ cancel: true }, while a valid owner remains visible.Node runtime evidence
Terminal capture of
node --import tsx /tmp/verify-thread-ownership-real.mjsagainst the real exported plugin entrypoint, a real local forwarder, and unmockedglobalThis.fetch:Validation
Direct dependency contract reviewed:
src/agents/provider-http-errors.ts:270boundsreadProviderJsonResponsewithreadResponseWithLimit, reports overflow with the supplied label, and normalizes malformed JSON. No config, migration, public SDK, or new dependency surface changed.