From 9949f6bd85000aaeebb6d2a2c2009c2461deb4a0 Mon Sep 17 00:00:00 2001 From: pick-cat Date: Tue, 30 Jun 2026 01:48:05 +0800 Subject: [PATCH] fix(chutes): bound OAuth token error response reads (#97808) * fix(chutes): bound OAuth token error response reads * ci: re-trigger checks (fs-safe unhandled-rejection flake on prior run) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Pick-cat <266665499+Pick-cat@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- src/agents/chutes-oauth.flow.test.ts | 78 ++++++++++++++++++++++++++++ src/agents/chutes-oauth.ts | 8 +-- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/agents/chutes-oauth.flow.test.ts b/src/agents/chutes-oauth.flow.test.ts index 9f9aec4e5a8e..a5ba4d36eff7 100644 --- a/src/agents/chutes-oauth.flow.test.ts +++ b/src/agents/chutes-oauth.flow.test.ts @@ -27,6 +27,28 @@ function createStoredCredential( } as unknown as Parameters[0]["credential"]; } +function cancelTrackedResponse( + text: string, + init: ResponseInit, +): { + response: Response; + wasCanceled: () => boolean; +} { + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + }, + cancel() { + canceled = true; + }, + }); + return { + response: new Response(stream, init), + wasCanceled: () => canceled, + }; +} + function expectRefreshedCredential( refreshed: Awaited>, now: number, @@ -212,6 +234,62 @@ describe("chutes-oauth", () => { expectRefreshedCredential(refreshed, now); }); + it("bounds token exchange error bodies without using response.text()", async () => { + const tracked = cancelTrackedResponse(`${"chutes exchange failure ".repeat(1024)}tail`, { + status: 401, + headers: { "content-type": "text/plain" }, + }); + const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); + const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = urlToString(input); + if (url === CHUTES_TOKEN_ENDPOINT) { + return tracked.response; + } + return new Response("not found", { status: 404 }); + }); + + await expect( + exchangeChutesCodeForTokens({ + app: { + clientId: "cid_test", + redirectUri: "http://127.0.0.1:1456/oauth-callback", + scopes: ["openid"], + }, + code: "code_401", + codeVerifier: "verifier_401", + fetchFn, + now: 1_000_000, + }), + ).rejects.toThrow("Chutes token exchange failed: chutes exchange failure"); + expect(textSpy).not.toHaveBeenCalled(); + expect(tracked.wasCanceled()).toBe(true); + }); + + it("bounds token refresh error bodies without using response.text()", async () => { + const tracked = cancelTrackedResponse(`${"chutes refresh failure ".repeat(1024)}tail`, { + status: 401, + headers: { "content-type": "text/plain" }, + }); + const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); + const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => { + const url = urlToString(input); + if (url === CHUTES_TOKEN_ENDPOINT) { + return tracked.response; + } + return new Response("not found", { status: 404 }); + }); + + await expect( + refreshChutesTokens({ + credential: createStoredCredential(5_000_000), + fetchFn, + now: 5_000_000, + }), + ).rejects.toThrow("Chutes token refresh failed: chutes refresh failure"); + expect(textSpy).not.toHaveBeenCalled(); + expect(tracked.wasCanceled()).toBe(true); + }); + it("rejects unsafe refresh token lifetimes", async () => { const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => { const url = urlToString(input); diff --git a/src/agents/chutes-oauth.ts b/src/agents/chutes-oauth.ts index 4d0d49f0930d..530c002dd669 100644 --- a/src/agents/chutes-oauth.ts +++ b/src/agents/chutes-oauth.ts @@ -6,7 +6,9 @@ import { createHash, randomBytes } from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { resolveExpiresAtMsFromDurationSeconds } from "../infra/parse-finite-number.js"; import type { OAuthCredentials } from "../llm/oauth.js"; -import { readProviderJsonResponse } from "./provider-http-errors.js"; +import { readProviderJsonResponse, readResponseTextLimited } from "./provider-http-errors.js"; + +const CHUTES_OAUTH_ERROR_BODY_LIMIT_BYTES = 8 * 1024; const CHUTES_OAUTH_ISSUER = "https://api.chutes.ai"; export const CHUTES_AUTHORIZE_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/authorize`; @@ -152,7 +154,7 @@ export async function exchangeChutesCodeForTokens(params: { body, }); if (!response.ok) { - const text = await response.text(); + const text = await readResponseTextLimited(response, CHUTES_OAUTH_ERROR_BODY_LIMIT_BYTES); throw new Error(`Chutes token exchange failed: ${text}`); } @@ -223,7 +225,7 @@ export async function refreshChutesTokens(params: { body, }); if (!response.ok) { - const text = await response.text(); + const text = await readResponseTextLimited(response, CHUTES_OAUTH_ERROR_BODY_LIMIT_BYTES); throw new Error(`Chutes token refresh failed: ${text}`); }