Skip to content

fix(googlechat): cancel unread fetchOk bodies before release - #111290

Merged
steipete merged 3 commits into
openclaw:mainfrom
hugenshen:fix/googlechat-fetchok-cancel-body
Jul 29, 2026
Merged

fix(googlechat): cancel unread fetchOk bodies before release#111290
steipete merged 3 commits into
openclaw:mainfrom
hugenshen:fix/googlechat-fetchok-cancel-body

Conversation

@hugenshen

Copy link
Copy Markdown
Contributor

What Problem This Solves

Google Chat status-only API calls (fetchOk, used by deleteGoogleChatMessage)
go through withGoogleChatResponsefetchWithSsrFGuard, then only need the
HTTP status. The finally path called release() without cancelling an unread
response body. release() tears down the SSRF dispatcher but does not
cancel streams, so undici can keep the TCP connection pinned after a successful
DELETE (or any other fetchOk caller).

JSON/buffer helpers already consume the body (bodyUsed), so only status-only
success paths leaked.

Why This Change Was Made

In withGoogleChatResponse's finally, cancel the unread body when
!response.bodyUsed before release(). That covers fetchOk without changing
fetchJson / fetchBuffer (they set bodyUsed first). Error responses already
consume the body via readGoogleChatErrorResponse.

User Impact

Before: A Chat API endpoint that streams or holds a successful status-only
body could leave the request socket open after delete (or other fetchOk)
returned.

After: Status-only successes cancel the unread body and release the socket
promptly. JSON/media reads and error parsing are unchanged.

Evidence

  • Changed: extensions/googlechat/src/api.ts,
    extensions/googlechat/src/api.fetchok.transport.test.ts
  • Production path: deleteGoogleChatMessagefetchOk
    withGoogleChatResponsefetchWithSsrFGuard
    (auditContext: "googlechat.api.ok") → cancel unread → release()
  • Compatibility: no Google Chat API / timeout / SSRF policy changes

Real behavior proof

Behavior or issue addressed

Successful Google Chat fetchOk (DELETE) must cancel unread bodies. Without
cancel, a streaming 200 body that never ends keeps
serverObservedSocketClose: false 250ms after the call returns.

Canonical reachability path

deleteGoogleChatMessagefetchOkwithGoogleChatResponse
fetchWithSsrFGuard DELETE → status OK → finally cancel unread → release()

Shared helper / provider constraint check

Uses existing fetchWithSsrFGuard. Same undici consume-or-cancel contract as
cron preflight / Discord voice CDN status-only paths
(#111226 / #111269).

Real environment tested

Production deleteGoogleChatMessage transport test (committed): loopback
open-body DELETE; SSRF guard proxied to undici fetch and rewritten onto
loopback so the Chat hostname can be exercised locally; production
cancel/release still run on a real Response. Auth is stubbed
(getGoogleChatAccessToken).

Cancel+release contract before/after (same open-stream DELETE shape):

Negative control (no cancel):

{"productionPath":"withGoogleChatResponse finally cancel+release (fetchOk/DELETE)","cancel":false,"returnedAtMs":23,"serverObservedSocketClose":false,"socketCloseAfterStartMs":null,"status":200}

After cancel:

{"productionPath":"withGoogleChatResponse finally cancel+release (fetchOk/DELETE)","cancel":true,"returnedAtMs":22,"serverObservedSocketClose":true,"socketCloseAfterStartMs":23,"status":200}

macOS, Node v22.23.1.

Evidence after fix

node scripts/run-vitest.mjs \
  extensions/googlechat/src/api.fetchok.transport.test.ts \
  --reporter=verbose

 Test Files  1 passed (1)
      Tests  1 passed (1)
[test] passed 1 Vitest shard in 36.58s
./node_modules/.bin/oxfmt --check \
  extensions/googlechat/src/api.ts \
  extensions/googlechat/src/api.fetchok.transport.test.ts
All matched files use the correct format.

Premise: Undici requires every response body to be consumed or canceled.

AI-assisted: Yes.

@openclaw-barnacle openclaw-barnacle Bot added channel: googlechat Channel integration: googlechat size: S labels Jul 19, 2026
@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. P2 Normal backlog priority with limited blast radius. labels Jul 19, 2026
@clawsweeper

clawsweeper Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed July 19, 2026, 3:55 AM ET / 07:55 UTC.

Summary
The PR cancels unread successful Google Chat status-only response bodies before releasing the SSRF dispatcher and adds a transport regression test around deleteGoogleChatMessage.

PR surface: Source +5, Tests +113. Total +118 across 2 files.

Reproducibility: yes. from source: deleteGoogleChatMessage reaches fetchOk, whose successful handler leaves the response unread before the current finally releases the dispatcher. The submitted transport test supplies a concrete open-body DELETE path, though this read-only review did not execute it independently.

Review metrics: none identified.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🐚 platinum hermit
Patch quality: 🐚 platinum hermit
Result: ready for maintainer review.

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

Rank-up moves:

  • [P2] Rebase onto current main and let the required checks validate the exact merge head.

Risk before merge

  • [P1] The branch is behind current main; refresh the merge result and required checks on the rebased head before landing.

Maintainer options:

  1. Decide the mitigation before merge
    Land the caller-local unread-body cancellation after a rebase confirms the focused transport test and required checks still pass; keep the general SSRF helper contract unchanged unless a separately reviewed cross-caller refactor establishes that every caller can safely delegate cleanup to it.
  2. Pause or close
    Do not merge this PR until maintainers decide whether the risk is worth taking.

Next step before merge

  • [P2] No repair lane is needed because this review found no discrete patch defect; the remaining action is ordinary rebase and merge-gate verification.

Security
Cleared: The patch does not add dependencies, permissions, secrets handling, network destinations, or SSRF-policy changes; it only cleans up an already-created response body before dispatcher release.

Review details

Best possible solution:

Land the caller-local unread-body cancellation after a rebase confirms the focused transport test and required checks still pass; keep the general SSRF helper contract unchanged unless a separately reviewed cross-caller refactor establishes that every caller can safely delegate cleanup to it.

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

Yes, from source: deleteGoogleChatMessage reaches fetchOk, whose successful handler leaves the response unread before the current finally releases the dispatcher. The submitted transport test supplies a concrete open-body DELETE path, though this read-only review did not execute it independently.

Is this the best way to solve the issue?

Yes. Cancelling only at the Google Chat status-only caller is the narrowest maintainable fix: it protects the unread fetchOk path without broadening the generic SSRF helper’s lifecycle contract for callers that may still need their response bodies.

AGENTS.md: found and applied where relevant.

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

Label changes

Label changes:

  • add P2: This fixes a bounded Google Chat outbound transport resource-lifecycle bug without changing user configuration, API semantics, or SSRF policy.
  • add proof: sufficient: Contributor real behavior proof is sufficient. The PR provides after-fix real Node transport output for the production delete path, including a negative control and observed server-side socket closure after cancellation; the committed test exercises the same path. Redact any private endpoint or credential data if refreshed proof is posted.
  • add rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🐚 platinum hermit and patch quality is 🐚 platinum hermit.
  • add status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR provides after-fix real Node transport output for the production delete path, including a negative control and observed server-side socket closure after cancellation; the committed test exercises the same path. Redact any private endpoint or credential data if refreshed proof is posted.

Label justifications:

  • P2: This fixes a bounded Google Chat outbound transport resource-lifecycle bug without changing user configuration, API semantics, or SSRF policy.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🐚 platinum hermit and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR provides after-fix real Node transport output for the production delete path, including a negative control and observed server-side socket closure after cancellation; the committed test exercises the same path. Redact any private endpoint or credential data if refreshed proof is posted.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR provides after-fix real Node transport output for the production delete path, including a negative control and observed server-side socket closure after cancellation; the committed test exercises the same path. Redact any private endpoint or credential data if refreshed proof is posted.
Evidence reviewed

PR surface:

Source +5, Tests +113. Total +118 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 5 0 +5
Tests 1 113 0 +113
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 118 0 +118

What I checked:

  • Current main behavior: withGoogleChatResponse reads error bodies and delegates JSON/buffer handlers, but its finally currently invokes release() directly; fetchOk intentionally returns without reading the successful response body, leaving the reported lifecycle gap on current main. (extensions/googlechat/src/api.ts:99, 58452de71188)
  • Proposed fix is narrowly scoped: The PR adds cancellation only when response.bodyUsed is false, before release(), so successful fetchOk responses are cleaned up while JSON, buffer, and parsed-error paths retain their current consumption behavior. (extensions/googlechat/src/api.ts:99, c4593ec0576e)
  • Regression proof targets the production route: The added transport test invokes deleteGoogleChatMessage through the production Google Chat response wrapper against a loopback server with an open response body; the PR body includes before/after socket-close output and a focused passing test run. (extensions/googlechat/src/api.fetchok.transport.test.ts:1, c4593ec0576e)
  • Sibling invariant is independently corroborated: The related open cleanup PRs for cron and Discord apply the same consume-or-cancel rule to distinct status-only fetchWithSsrFGuard callers; they are adjacent implementations rather than replacements for this Google Chat fix.
  • Feature-history ownership: Google Chat’s shared-plugin-base refactor appears to date to 67ddc1a3e13f1bf606b3f252dc3abfde992e9298 by vincentkoc, while the directory history shows recurring recent Google Chat maintenance by steipete and franco-viotti. (extensions/googlechat/src/api.ts:1, 67ddc1a3e13f)

Likely related people:

  • vincentkoc: Introduced the shared Google Chat plugin-base refactor that owns the surrounding extension structure. (role: recent area contributor; confidence: high; commits: 67ddc1a3e13f; files: extensions/googlechat/src/api.ts)
  • steipete: Appears repeatedly in recent Google Chat directory history, including release and channel maintenance commits affecting this package. (role: recent area contributor; confidence: medium; commits: a14eacf, 8e28c77, 7dbc770; files: extensions/googlechat/src/api.ts)
  • franco-viotti: Authored a recent merged Google Chat reply-thread behavior fix in the same extension. (role: feature contributor; confidence: medium; commits: a71b121c69f0; files: extensions/googlechat/src/api.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.

@Yigtwxx Yigtwxx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The !response.bodyUsed guard is the right shape here — it covers the status-only fetchOk path without double-consuming the JSON/buffer handlers that already read the stream, and putting it in finally means it also runs when handleResponse throws.

I checked this against the repo-wide convention and it matches: the same !bodyUsed → cancel().catch(() => undefined) pattern is well established across the guard consumers, so this isn't a local invention.

Two notes: one site in this extension that I believe the sweep missed, and one about what the new test actually establishes. Both inline.

} finally {
// fetchOk (status-only) leaves the body unread. JSON/buffer handlers set
// bodyUsed first. release() does not cancel streams.
if (!response.bodyUsed) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment states the invariant precisely — and there's a third guard site in this same extension where it's violated, which I think is a genuine miss rather than out of scope.

extensions/googlechat/src/google-auth.runtime.ts:426-436 (not in this diff):

  try {
    const body = await readGoogleAuthResponseBytes(response);
    ...
  } finally {
    await release();          // :435 — no cancel
  }

readGoogleAuthResponseBytes can throw before it ever touches the stream — the size check at :444-446 runs against the content-length header, while response.body?.getReader() isn't reached until :449:

if (contentLength !== null && contentLength > MAX_GOOGLE_AUTH_RESPONSE_BYTES) {
  throw new Error(`Google auth response exceeds ${MAX_GOOGLE_AUTH_RESPONSE_BYTES} bytes.`);
}

So a Google auth endpoint advertising an oversized Content-Length unwinds to that finally with bodyUsed === false and response.body !== null — exactly the state your comment says release() doesn't handle.

What convinced me this is an oversight and not a deliberate boundary: the extension's third guard site already does it correctly. extensions/googlechat/src/auth.ts:123:

if (!response.ok) {
  await response.body?.cancel().catch(() => undefined);
  throw new Error(`Failed to fetch Chat certs (${response.status})`);
}

So googlechat had three sites — one already compliant, one fixed here, one left. Same shape as this hunk would close it:

} finally {
  if (!response.bodyUsed) {
    await response.body?.cancel().catch(() => undefined);
  }
  await release();
}

return {
response,
finalUrl: url,
release: async () => {},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Smaller point, about what this proves rather than whether it passes.

Using a real loopback server and a real undici Response is the right call — the socket-close assertion is meaningful, and it's much better than a fabricated response object. But release is stubbed to a no-op here, and the thesis in the production comment is specifically "release() does not cancel streams". The real release() reaches closeDispatcherwaitForDispatcherClose (src/infra/net/ssrf.ts:734-761), which calls dispatcher.close() and escalates to destroyDispatcher() on timeout — i.e. it does tear the socket down, just not promptly or via the stream.

So the test currently establishes "cancel() closes the socket", which would hold even if release() were sufficient. It doesn't establish that the leak exists on the real path.

Letting the real release run — return the guard's own release rather than async () => {} — would close that gap, or alternatively one extra case asserting the socket stays open when only release() runs. The sibling PR #111275 (tlon) ends up with this property for free by driving the real fetchWithSsrFGuard with ssrfPolicy: { allowPrivateNetwork: true } plus a loopback lookupFn instead of mocking the module, if you want a shape to copy.

@Yigtwxx

Yigtwxx commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Second pass, since the test surface grew from +113 to +299 after my earlier review and nothing had re-read it.

The stubbed-release point is properly addressed, and I verified it rather than taking the diff at face value. The harness now wraps the real guard and delegates: release records bodyIsNull / bodyUsed and then calls await guarded.release(), so the assertions run against closeDispatcherwaitForDispatcherClose instead of a no-op. The three added cases (tee'd capture, no-content, rejected cancellation) cover the shapes that made the original single case unconvincing.

The tests are load-bearing. Removing just the new guard from api.ts:

    if (!response.bodyUsed) {
      void response.body?.cancel().catch(() => undefined);
    }

turns 2 of 6 red, and restoring it returns 6/6. So the file pins this fix specifically, not incidental behaviour.

My other point is still open, and I can now show it instead of asserting it. google-auth.runtime.ts:426-436 is untouched by this PR and still has the pre-fix shape:

try {
  const body = await readGoogleAuthResponseBytes(response);
  ...
} finally {
  await release();          // no cancel
}

readGoogleAuthResponseBytes checks content-length at :440-446 and throws before response.body?.getReader() at :448, so an oversized response never touches the stream.

I measured it with the same harness style this PR uses — a real loopback server, the real fetchWithSsrFGuard, and the real release — recording what release observes, with a control case:

case release() observes
normal response (control) { bodyIsNull: false, bodyUsed: true }
content-length: 1048577 { bodyIsNull: false, bodyUsed: false }

The second row is exactly the state api.ts now guards against: a live body, never read, released without cancellation. The control row matters as much — it shows the site is not leaking generally, only on the throw-before-read path, which is why reading the code alone makes it easy to miss.

Worth noting the trigger is reachable without a hostile server: any auth response advertising more than 1 MiB gets there, and the size guard exists precisely because that is considered possible.

Not asking to widen this PR — the api.ts boundary is coherent on its own and the evidence here is good. Flagging it so the second site does not sit behind a comment that now documents the invariant it violates. Happy to send it as a follow-up if you'd rather keep this one focused.

@steipete
steipete merged commit 0316368 into openclaw:main Jul 29, 2026
90 of 92 checks passed
@steipete

Copy link
Copy Markdown
Contributor

Merged via squash.

Yigtwxx added a commit to Yigtwxx/openclaw that referenced this pull request Jul 29, 2026
The size guard in readGoogleAuthResponseBytes inspects content-length and
throws before it reaches response.body.getReader(), so an oversized auth
response leaves the stream untouched and the finally block releases the
dispatcher without cancelling it.

This is the same shape openclaw#111290 just fixed in api.ts, applied to the auth
transport's own guard site.
Yigtwxx added a commit to Yigtwxx/openclaw that referenced this pull request Jul 29, 2026
The size guard in readGoogleAuthResponseBytes inspects content-length and
throws before it reaches response.body.getReader(), so an oversized auth
response leaves the stream untouched and the finally block releases the
dispatcher without cancelling it.

This is the same shape openclaw#111290 just fixed in api.ts, applied to the auth
transport's own guard site.
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jul 30, 2026
…w#111290)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
steipete pushed a commit that referenced this pull request Aug 1, 2026
…115873)

The size guard in readGoogleAuthResponseBytes inspects content-length and
throws before it reaches response.body.getReader(), so an oversized auth
response leaves the stream untouched and the finally block releases the
dispatcher without cancelling it.

This is the same shape #111290 just fixed in api.ts, applied to the auth
transport's own guard site.
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Aug 1, 2026
…penclaw#115873)

The size guard in readGoogleAuthResponseBytes inspects content-length and
throws before it reaches response.body.getReader(), so an oversized auth
response leaves the stream untouched and the finally block releases the
dispatcher without cancelling it.

This is the same shape openclaw#111290 just fixed in api.ts, applied to the auth
transport's own guard site.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: googlechat Channel integration: googlechat P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary 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.

3 participants