diff --git a/extensions/clawrouter/usage.test.ts b/extensions/clawrouter/usage.test.ts index 9cd33dcc223c..90c10b883b8b 100644 --- a/extensions/clawrouter/usage.test.ts +++ b/extensions/clawrouter/usage.test.ts @@ -222,20 +222,61 @@ describe("ClawRouter usage", () => { expect(snapshot.summary).toBeUndefined(); }); - it("rejects usage JSON containing invalid UTF-8", async () => { + it.each([ + ["malformed JSON", new TextEncoder().encode('{"budget":')], + ["a non-object JSON root", new TextEncoder().encode("null")], + ])("reports %s as a malformed usage response", async (_label, body) => { + const snapshot = await fetchClawRouterUsage({ + token: "test-token", + timeoutMs: 5000, + fetchGuard: mockFetchGuard(new Response(body)), + }); + + expect(snapshot).toEqual({ + provider: "clawrouter", + displayName: "ClawRouter", + windows: [], + error: "Malformed usage response", + }); + }); + + it("reports invalid UTF-8 as a malformed usage response", async () => { const prefix = new TextEncoder().encode( '{"budget":{"configured":true,"windowKey":"default/test-policy/2026-', ); const suffix = new TextEncoder().encode('","limitMicros":1000000,"spentMicros":500000}}'); const body = new Uint8Array([...prefix, 0xff, ...suffix]); + const snapshot = await fetchClawRouterUsage({ + token: "test-token", + timeoutMs: 5000, + fetchGuard: mockFetchGuard(new Response(body)), + }); + + expect(snapshot).toEqual({ + provider: "clawrouter", + displayName: "ClawRouter", + windows: [], + error: "Malformed usage response", + }); + }); + + it("preserves response stream failures as transport errors", async () => { + const response = new Response( + new ReadableStream({ + start(controller) { + controller.error(new TypeError("usage stream failed")); + }, + }), + ); + await expect( fetchClawRouterUsage({ token: "test-token", timeoutMs: 5000, - fetchGuard: mockFetchGuard(new Response(body)), + fetchGuard: mockFetchGuard(response), }), - ).rejects.toThrow(TypeError); + ).rejects.toThrow("usage stream failed"); }); it("cancels non-OK usage response body before throwing", async () => { diff --git a/extensions/clawrouter/usage.ts b/extensions/clawrouter/usage.ts index e6425ef1dcb4..dee85c9f75ec 100644 --- a/extensions/clawrouter/usage.ts +++ b/extensions/clawrouter/usage.ts @@ -5,7 +5,7 @@ import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; -import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumberInRange, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeClawRouterRootUrl } from "./provider-catalog.js"; const CLAWROUTER_USAGE_RESPONSE_MAX_BYTES = 1024 * 1024; @@ -72,16 +72,19 @@ function buildSummary(payload: ClawRouterUsagePayload): string | undefined { async function readClawRouterUsagePayload( response: Response, timeoutMs: number, -): Promise { +): Promise { const buffer = await readResponseWithLimit(response, CLAWROUTER_USAGE_RESPONSE_MAX_BYTES, { chunkTimeoutMs: timeoutMs, onOverflow: ({ maxBytes }) => new Error(`ClawRouter usage response exceeds ${maxBytes} bytes`), onIdleTimeout: ({ chunkTimeoutMs }) => new Error(`ClawRouter usage response stalled: no data received for ${chunkTimeoutMs}ms`), }); - return JSON.parse( - new TextDecoder("utf-8", { fatal: true }).decode(buffer), - ) as ClawRouterUsagePayload; + try { + const payload: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer)); + return asOptionalRecord(payload) as ClawRouterUsagePayload | undefined; + } catch { + return undefined; + } } export async function fetchClawRouterUsage(params: { @@ -116,6 +119,14 @@ export async function fetchClawRouterUsage(params: { throw new Error(`ClawRouter usage request failed (HTTP ${response.status})`); } const payload = await readClawRouterUsagePayload(response, params.timeoutMs); + if (!payload) { + return { + provider: "clawrouter", + displayName: "ClawRouter", + windows: [], + error: "Malformed usage response", + }; + } const budget = payload.budget; const limitMicros = asFiniteNumberInRange(budget?.limitMicros, { min: 0 }); const spentMicros = asFiniteNumberInRange(budget?.spentMicros, { min: 0 });