fix(telegram): guard ingress worker JSON.parse against malformed Bot API bodies#98372
fix(telegram): guard ingress worker JSON.parse against malformed Bot API bodies#98372miorbnli wants to merge 2 commits into
Conversation
|
Codex review: needs maintainer review before merge. Reviewed July 8, 2026, 12:19 AM ET / 04:19 UTC. Summary PR surface: Source +9, Tests +32. Total +41 across 3 files. Reproducibility: yes. at source level. Current main still rethrows the raw JSON.parse error for HTTP 200 non-JSON getUpdates bodies and the worker rethrows non-retryable errors; the PR discussion also includes live local-server proof for the fixed path. Review metrics: 1 noteworthy metric.
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
Maintainer decision needed
Security Review detailsBest possible solution: Land the status-aware malformed-body guard after maintainer acceptance of the shared retry-classifier scope, preserving fatal 4xx/409 handling and conservative send behavior. Do we have a high-confidence way to reproduce the issue? Yes, at source level. Current main still rethrows the raw JSON.parse error for HTTP 200 non-JSON getUpdates bodies and the worker rethrows non-retryable errors; the PR discussion also includes live local-server proof for the fixed path. Is this the best way to solve the issue? Yes. Catching inside fetchJson is the narrowest maintainable owner boundary because it still has response.ok/status available, while the classifier tests preserve fatal 4xx/409 and conservative send behavior. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 827f2c44225e. Label changesLabel justifications:
Evidence reviewedPR surface: Source +9, Tests +32. Total +41 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
Review history (1 earlier review cycle)
|
runQmdSearchViaMcporter parsed mcporter subprocess stdout with JSON.parse outside the runMcporter try/catch (qmd-manager.ts:2722). A non-JSON stdout (daemon warning bleeding onto stdout, output truncated by maxOutputChars, CLI killed early, or flag mismatch) threw a raw SyntaxError that propagated uncaught out of runQmdSearchViaMcporter, surfacing in agent logs as a context-free SyntaxError with no hint of the actual mcporter failure. Wrap JSON.parse in try/catch and throw a typed Error carrying a stdout snippet (matching the guard pattern already used in parseListedCollections in this file, and the recent matrix openclaw#97973 / sms openclaw#97999 / signal openclaw#98073 / telegram-ingress openclaw#98372 JSON.parse guard series). Co-Authored-By: Claude <[email protected]>
runQmdSearchViaMcporter parsed mcporter subprocess stdout with JSON.parse outside the runMcporter try/catch (qmd-manager.ts:2722). A non-JSON stdout (daemon warning bleeding onto stdout, output truncated by maxOutputChars, CLI killed early, or flag mismatch) threw a raw SyntaxError that propagated uncaught out of runQmdSearchViaMcporter, surfacing in agent logs as a context-free SyntaxError with no hint of the actual mcporter failure. Wrap JSON.parse in try/catch and throw a typed Error carrying a stdout snippet (matching the guard pattern already used in parseListedCollections in this file, and the recent matrix openclaw#97973 / sms openclaw#97999 / signal openclaw#98073 / telegram-ingress openclaw#98372 JSON.parse guard series). Co-Authored-By: Claude <[email protected]>
a94f690 to
b976ad4
Compare
runQmdSearchViaMcporter parsed mcporter subprocess stdout with JSON.parse outside the runMcporter try/catch (qmd-manager.ts:2722). A non-JSON stdout (daemon warning bleeding onto stdout, output truncated by maxOutputChars, CLI killed early, or flag mismatch) threw a raw SyntaxError that propagated uncaught out of runQmdSearchViaMcporter, surfacing in agent logs as a context-free SyntaxError with no hint of the actual mcporter failure. Wrap JSON.parse in try/catch and throw a typed Error carrying a stdout snippet (matching the guard pattern already used in parseListedCollections in this file, and the recent matrix openclaw#97973 / sms openclaw#97999 / signal openclaw#98073 / telegram-ingress openclaw#98372 JSON.parse guard series). Co-Authored-By: Claude <[email protected]>
|
@clawsweeper re-review Updated the Real behavior proof section with non-mocked current-head evidence: a real local HTTP server standing in for the Telegram Bot API (returns non-JSON 503) + the real exported runTelegramIngressWorkerRuntime (globalThis.fetch is NOT replaced). Commit SHA updated to current head b976ad4. |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
@clawsweeper re-review Added the unique HTTP 200 malformed proof ClawSweeper requested: Part 1 (real worker + real HTTP server, globalThis.fetch NOT replaced):
Part 2 (real isRecoverableTelegramNetworkError/isRetryableTelegramApiError):
Evidence output from Part 1: Evidence output from Part 2: |
b976ad4 to
a523ead
Compare
runQmdSearchViaMcporter parsed mcporter subprocess stdout with JSON.parse outside the runMcporter try/catch (qmd-manager.ts:2722). A non-JSON stdout (daemon warning bleeding onto stdout, output truncated by maxOutputChars, CLI killed early, or flag mismatch) threw a raw SyntaxError that propagated uncaught out of runQmdSearchViaMcporter, surfacing in agent logs as a context-free SyntaxError with no hint of the actual mcporter failure. Wrap JSON.parse in try/catch and throw a typed Error carrying a stdout snippet (matching the guard pattern already used in parseListedCollections in this file, and the recent matrix openclaw#97973 / sms openclaw#97999 / signal openclaw#98073 / telegram-ingress openclaw#98372 JSON.parse guard series). Co-Authored-By: Claude <[email protected]>
…ut (#98381) * fix(memory-core): guard qmd mcporter JSON.parse against non-JSON stdout runQmdSearchViaMcporter parsed mcporter subprocess stdout with JSON.parse outside the runMcporter try/catch (qmd-manager.ts:2722). A non-JSON stdout (daemon warning bleeding onto stdout, output truncated by maxOutputChars, CLI killed early, or flag mismatch) threw a raw SyntaxError that propagated uncaught out of runQmdSearchViaMcporter, surfacing in agent logs as a context-free SyntaxError with no hint of the actual mcporter failure. Wrap JSON.parse in try/catch and throw a typed Error carrying a stdout snippet (matching the guard pattern already used in parseListedCollections in this file, and the recent matrix #97973 / sms #97999 / signal #98073 / telegram-ingress #98372 JSON.parse guard series). Co-Authored-By: Claude <[email protected]> * fix(memory-core): preserve cause in qmd mcporter JSON.parse guard Add { cause: err } to the re-thrown Error to satisfy the preserve-caught-error lint rule; the original SyntaxError is now chained, improving diagnosability. Co-Authored-By: Claude <[email protected]> * fix(memory-core): redact raw stdout from qmd mcporter error (security-boundary) ClawSweeper flagged that the previous error message exposed raw mcporter stdout (first 200 chars) before session visibility filtering, which could leak sensitive content. Drop the stdout preview from the thrown message; keep the original SyntaxError as `cause` for diagnostics so the parse-failure reason is still reachable without surfacing unfiltered subprocess output. Co-Authored-By: Claude <[email protected]> * fix(memory-core): keep qmd mcporter error message generic (no raw stdout leak) The SyntaxError thrown by JSON.parse embeds a snippet of the raw input in its message (e.g. Unexpected token '<', then the raw bytes). Including that SyntaxError message in the thrown Error would surface unfiltered mcporter stdout before session visibility filtering. Drop the parse-error message from the thrown Error; keep the original SyntaxError as cause for developer diagnostics only. Co-Authored-By: Claude <[email protected]> * fix(memory-core): keep qmd mcporter cause generic (no raw stdout leak via formatErrorMessage) formatErrorMessage walks the .cause chain into the user-visible path. Keeping the JSON.parse SyntaxError on .cause leaked its embedded raw stdout snippet through formatErrorMessage even with a generic message. Give the cause a generic message too; the raw snippet no longer reaches the user. Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
…ut (openclaw#98381) * fix(memory-core): guard qmd mcporter JSON.parse against non-JSON stdout runQmdSearchViaMcporter parsed mcporter subprocess stdout with JSON.parse outside the runMcporter try/catch (qmd-manager.ts:2722). A non-JSON stdout (daemon warning bleeding onto stdout, output truncated by maxOutputChars, CLI killed early, or flag mismatch) threw a raw SyntaxError that propagated uncaught out of runQmdSearchViaMcporter, surfacing in agent logs as a context-free SyntaxError with no hint of the actual mcporter failure. Wrap JSON.parse in try/catch and throw a typed Error carrying a stdout snippet (matching the guard pattern already used in parseListedCollections in this file, and the recent matrix openclaw#97973 / sms openclaw#97999 / signal openclaw#98073 / telegram-ingress openclaw#98372 JSON.parse guard series). Co-Authored-By: Claude <[email protected]> * fix(memory-core): preserve cause in qmd mcporter JSON.parse guard Add { cause: err } to the re-thrown Error to satisfy the preserve-caught-error lint rule; the original SyntaxError is now chained, improving diagnosability. Co-Authored-By: Claude <[email protected]> * fix(memory-core): redact raw stdout from qmd mcporter error (security-boundary) ClawSweeper flagged that the previous error message exposed raw mcporter stdout (first 200 chars) before session visibility filtering, which could leak sensitive content. Drop the stdout preview from the thrown message; keep the original SyntaxError as `cause` for diagnostics so the parse-failure reason is still reachable without surfacing unfiltered subprocess output. Co-Authored-By: Claude <[email protected]> * fix(memory-core): keep qmd mcporter error message generic (no raw stdout leak) The SyntaxError thrown by JSON.parse embeds a snippet of the raw input in its message (e.g. Unexpected token '<', then the raw bytes). Including that SyntaxError message in the thrown Error would surface unfiltered mcporter stdout before session visibility filtering. Drop the parse-error message from the thrown Error; keep the original SyntaxError as cause for developer diagnostics only. Co-Authored-By: Claude <[email protected]> * fix(memory-core): keep qmd mcporter cause generic (no raw stdout leak via formatErrorMessage) formatErrorMessage walks the .cause chain into the user-visible path. Keeping the JSON.parse SyntaxError on .cause leaked its embedded raw stdout snippet through formatErrorMessage even with a generic message. Give the cause a generic message too; the raw snippet no longer reaches the user. Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
…API bodies fetchJson in telegram-ingress-worker.runtime.ts parsed the Bot API response body with JSON.parse but did not catch SyntaxError. A non-JSON body (HTML error page, empty, or truncated from a CDN, reverse proxy, or TLS MITM) made the SyntaxError bubble to the long-poll loop, where isRecoverableTelegramNetworkError judged it non-recoverable (SyntaxError is not in the recoverable code/name/message sets), so the loop re-threw and the isolated ingress worker exited — silently dropping the Telegram channel until manual restart. Wrap JSON.parse in try/catch and throw a descriptive Error carrying statusCode (matching the matrix openclaw#97973 / sms openclaw#97999 / signal openclaw#98073 pattern). Add "malformed json" to RECOVERABLE_MESSAGE_SNIPPETS so the polling/webhook contexts back off and retry instead of exiting; send context stays conservative (allowMessageMatch defaults false) to avoid duplicate-message retries. Co-Authored-By: Claude <[email protected]>
…ecoverable) ClawSweeper flagged that the malformed-json snippet is too broad: a 4xx response (bad token, apiRoot misconfig) with a non-JSON body would match the snippet and retry indefinitely instead of surfacing as a fatal polling error. Split the fetchJson catch: 4xx client errors produce a message without "malformed json", so the polling loop treats them as non-recoverable (fatal). Server/transient statuses (5xx, 2xx) keep the recoverable path (backoff retry). Add 4xx regression test. Co-Authored-By: Claude <[email protected]>
a523ead to
9b7e932
Compare
…ut (openclaw#98381) * fix(memory-core): guard qmd mcporter JSON.parse against non-JSON stdout runQmdSearchViaMcporter parsed mcporter subprocess stdout with JSON.parse outside the runMcporter try/catch (qmd-manager.ts:2722). A non-JSON stdout (daemon warning bleeding onto stdout, output truncated by maxOutputChars, CLI killed early, or flag mismatch) threw a raw SyntaxError that propagated uncaught out of runQmdSearchViaMcporter, surfacing in agent logs as a context-free SyntaxError with no hint of the actual mcporter failure. Wrap JSON.parse in try/catch and throw a typed Error carrying a stdout snippet (matching the guard pattern already used in parseListedCollections in this file, and the recent matrix openclaw#97973 / sms openclaw#97999 / signal openclaw#98073 / telegram-ingress openclaw#98372 JSON.parse guard series). Co-Authored-By: Claude <[email protected]> * fix(memory-core): preserve cause in qmd mcporter JSON.parse guard Add { cause: err } to the re-thrown Error to satisfy the preserve-caught-error lint rule; the original SyntaxError is now chained, improving diagnosability. Co-Authored-By: Claude <[email protected]> * fix(memory-core): redact raw stdout from qmd mcporter error (security-boundary) ClawSweeper flagged that the previous error message exposed raw mcporter stdout (first 200 chars) before session visibility filtering, which could leak sensitive content. Drop the stdout preview from the thrown message; keep the original SyntaxError as `cause` for diagnostics so the parse-failure reason is still reachable without surfacing unfiltered subprocess output. Co-Authored-By: Claude <[email protected]> * fix(memory-core): keep qmd mcporter error message generic (no raw stdout leak) The SyntaxError thrown by JSON.parse embeds a snippet of the raw input in its message (e.g. Unexpected token '<', then the raw bytes). Including that SyntaxError message in the thrown Error would surface unfiltered mcporter stdout before session visibility filtering. Drop the parse-error message from the thrown Error; keep the original SyntaxError as cause for developer diagnostics only. Co-Authored-By: Claude <[email protected]> * fix(memory-core): keep qmd mcporter cause generic (no raw stdout leak via formatErrorMessage) formatErrorMessage walks the .cause chain into the user-visible path. Keeping the JSON.parse SyntaxError on .cause leaked its embedded raw stdout snippet through formatErrorMessage even with a generic message. Give the cause a generic message too; the raw snippet no longer reaches the user. Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
What Problem This Solves
fetchJsoninextensions/telegram/src/telegram-ingress-worker.runtime.tsparsed the Bot API response body withJSON.parsebut did not catch theSyntaxError. A non-JSON body (HTML error page, empty, or truncated from a CDN, reverse proxy, or TLS MITM) made theSyntaxErrorbubble out offetchJsoninto the long-poll loop, whereisRecoverableTelegramNetworkErrorjudged it non-recoverable (SyntaxErroris not in the recoverable code/name/message sets). The loop re-threw, the isolated ingress worker exited, and the Telegram channel went silent until manual restart.Why This Change Was Made
This is the same JSON.parse guard pattern already shipped for the other bundled surfaces this week: matrix (#97973), sms (#97999), signal (#98073), plugin-sdk (#98125), nodes. The Telegram isolated ingress worker was the remaining unguarded surface, and its failure mode is worse than a single failed message — it takes down the whole inbound channel.
The matrix-style guard alone (try/catch + throw a descriptive Error) is not sufficient here, because the long-poll loop decides retry vs exit via
isRecoverableTelegramNetworkError. So the guard is split: a non-2xx response throwscreateTelegramGetUpdatesError({ errorCode })so the loop preserves Bot API HTTP status semantics (5xx/429 -> retry, 409 -> propagate, 401/404 -> fatal), and an HTTP 200 with a non-JSON body throws a descriptive Error whose message matches the"malformed json"entry inRECOVERABLE_MESSAGE_SNIPPETSso polling/webhook contexts (allowMessageMatch: true) back off and retry whilesendcontext (allowMessageMatch: false) stays conservative.User Impact
A Telegram Bot API endpoint that temporarily returns a non-JSON body (Cloudflare/intermediary HTML error, empty body, truncated response) no longer kills the isolated ingress worker. The worker backs off and retries like it does for other transient network errors, keeping the channel connected. Legitimate JSON responses, send-path behavior, and the happy path are unchanged.
Rebase note
Rebased onto current
main(upstream/mainadvanced past the original base). No behavioral change: the two original commits apply cleanly; the only conflict was an insertion-order collision innetwork-errors.test.tswheremain(#101258) added adelete/react/edit/actionidempotent-contexts test at the same spot this branch adds its malformed-JSON tests. Resolved by keeping both — the upstreamdelete/react/edit/actiontest plus this PR'''s two new malformed-JSON tests.network-errors.tsandtelegram-ingress-worker.runtime.tsapplied unchanged (auto-merged).Focused suite after rebase:
node scripts/run-vitest.mjs extensions/telegram/src/network-errors.test.ts extensions/telegram/src/telegram-ingress-worker.runtime.test.ts-> 66 passed (60 network-errors + 6 ingress worker).Real behavior proof
Behavior addressed: A non-JSON Bot API body (HTML error page / empty / truncated) threw an uncaught
SyntaxErrorout offetchJson; the long-poll loop judged it non-recoverable and the isolated ingress worker exited, silently dropping the Telegram channel.Real environment tested: Linux x86_64, Node v24.14.0, commit
9b7e9322e8on branchfix/telegram-ingress-malformed-json(current head, after the 4xx-control follow-up).Exact steps or command run after this patch:
node --import tsx verify-ingress-real.mjs— stands up a real local HTTP server standing in for the Telegram Bot API that returns a non-JSON503HTML body on everygetUpdatescall, then calls the real exportedrunTelegramIngressWorkerRuntime.globalThis.fetchis not replaced, so the production fetch path handles the request over a real socket. The port records every message the worker emits.Evidence after fix: Terminal capture of
noderuntime verification (live stdout, copied verbatim — non-mocked, real sockets):Observed result after fix: Against real sockets (no mocked fetch), a non-JSON
503Bot API body makesfetchJsonthrowcreateTelegramGetUpdatesError({ errorCode: 503 })(messageTelegram getUpdates failed with HTTP 503), whichisTelegramServerErrorrecognizes as 5xx, so the long-poll loop backs off and retries instead of exiting. Three consecutive malformed responses produced threepoll-errorevents and three real HTTP requests — the worker stayed alive and kept the channel connected. TheerrorCode=503carried on the emittedpoll-errorconfirms Bot API HTTP status semantics are preserved across the worker boundary (409 would propagate, 401/404 stay fatal) — the 4xx-control behavior on the current head.What was not tested: Live end-to-end against the real
api.telegram.orgedge returning HTML (the local HTTP server standing in for the Bot API is the closest non-mocked reproduction of the network path; the worker is the real production entry point withglobalThis.fetchuntouched).Tests and validation
Added two regression cases to
network-errors.test.ts: the malformed-JSON Error thrown byfetchJsonfor an HTTP 200 non-JSON body is recoverable forpolling/webhook(backoff) and non-recoverable forsend(conservative); and a 4xx malformed-body error stays non-recoverable (client error, no indefinite retry).fetchJsonitself is a file-local worker entry, so its end-to-end guard behavior is covered by the non-mockedrunTelegramIngressWorkerRuntimeruntime proof above.Risk checklist
extensions/telegram/) and one snippet in the sharednetwork-errors.ts; no public/plugin SDK API change. The"malformed json"snippet only matches the descriptive Error this same PR adds, andsendcontext keepsallowMessageMatch=falseso it cannot widen send-path retries."malformed json"snippet lives in sharednetwork-errors.ts- but it only matches a message string no existing caller produces today, and only takes effect whenallowMessageMatchis true (polling/webhook/unknown), never insend.runTelegramIngressWorkerRuntimeruntime verification showing the worker survives repeated malformed Bot API bodies.Closes: N/A (no existing issue)