Skip to content

Commit af6a483

Browse files
committed
fix(github-copilot): bound usage response
The Copilot usage read in extensions/github-copilot/usage.ts parsed its HTTP response with an unbounded await res.json(). A hostile or buggy api.github.com proxy (the proxy endpoint is derived from a user-supplied token) could stream an unbounded JSON body and drive the usage snapshot into OOM. Route the read through the shared readProviderJsonResponse (from openclaw/plugin-sdk/provider-http), which enforces the 16 MiB byte cap, cancels the stream on overflow, and wraps malformed JSON with the caller label. Same no-helper-import-to-bounded-reader shape as the #96027 / #96038 response-limit work. Add a focused regression test: when the usage stream exceeds the JSON byte cap, fetchCopilotUsage rejects with a bounded-overflow error and the reader cancels the body mid-flight instead of buffering the full advertised stream. Existing parse/HTTP-error cases keep passing.
1 parent 3ab8d6a commit af6a483

2 files changed

Lines changed: 46 additions & 1 deletion

File tree

extensions/github-copilot/models.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,47 @@ describe("fetchCopilotUsage", () => {
267267
plan: "free",
268268
});
269269
});
270+
271+
it("bounds the usage read and cancels the stream when the body exceeds the JSON byte cap", async () => {
272+
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels the
273+
// stream mid-flight; if the cap were removed the unbounded res.json() would buffer the whole body.
274+
const ONE_MIB = 1024 * 1024;
275+
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
276+
const chunk = new Uint8Array(ONE_MIB);
277+
278+
let bytesPulled = 0;
279+
let canceled = false;
280+
const makeOversizedJsonResponse = (): Response => {
281+
let pulled = 0;
282+
const body = new ReadableStream<Uint8Array>({
283+
pull(controller) {
284+
if (pulled >= TOTAL_CHUNKS) {
285+
controller.close();
286+
return;
287+
}
288+
pulled += 1;
289+
bytesPulled += chunk.length;
290+
controller.enqueue(chunk);
291+
},
292+
cancel() {
293+
canceled = true;
294+
},
295+
});
296+
return new Response(body, {
297+
status: 200,
298+
headers: { "Content-Type": "application/json" },
299+
});
300+
};
301+
302+
const mockFetch = createProviderUsageFetch(async () => makeOversizedJsonResponse());
303+
304+
await expect(fetchCopilotUsage("token", 5000, mockFetch)).rejects.toThrow(
305+
/github-copilot-usage: JSON response exceeds/,
306+
);
307+
// The bounded reader cancels the body and never pulls the full advertised 32 MiB stream.
308+
expect(canceled).toBe(true);
309+
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
310+
});
270311
});
271312

272313
describe("github-copilot token", () => {

extensions/github-copilot/usage.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Github Copilot plugin module implements usage behavior.
22
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
3+
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
34
import {
45
buildUsageHttpErrorSnapshot,
56
fetchJson,
@@ -41,7 +42,10 @@ export async function fetchCopilotUsage(
4142
});
4243
}
4344

44-
const data = (await res.json()) as CopilotUsageResponse;
45+
const data = await readProviderJsonResponse<CopilotUsageResponse>(
46+
res,
47+
"github-copilot-usage",
48+
);
4549
const windows: UsageWindow[] = [];
4650

4751
if (data.quota_snapshots?.premium_interactions) {

0 commit comments

Comments
 (0)