Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
  • Loading branch information
Alix-007 committed Jun 25, 2026
commit af6a48358fa8826a59549b4dbaa8f67cf703bd11
41 changes: 41 additions & 0 deletions extensions/github-copilot/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,47 @@ describe("fetchCopilotUsage", () => {
plan: "free",
});
});

it("bounds the usage read and cancels the stream when the body exceeds the JSON byte cap", async () => {
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels the
// stream mid-flight; if the cap were removed the unbounded res.json() would buffer the whole body.
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
const chunk = new Uint8Array(ONE_MIB);

let bytesPulled = 0;
let canceled = false;
const makeOversizedJsonResponse = (): Response => {
let pulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (pulled >= TOTAL_CHUNKS) {
controller.close();
return;
}
pulled += 1;
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "application/json" },
});
};

const mockFetch = createProviderUsageFetch(async () => makeOversizedJsonResponse());

await expect(fetchCopilotUsage("token", 5000, mockFetch)).rejects.toThrow(
/github-copilot-usage: JSON response exceeds/,
);
// The bounded reader cancels the body and never pulls the full advertised 32 MiB stream.
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
});
});

describe("github-copilot token", () => {
Expand Down
6 changes: 5 additions & 1 deletion extensions/github-copilot/usage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Github Copilot plugin module implements usage behavior.
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import {
buildUsageHttpErrorSnapshot,
fetchJson,
Expand Down Expand Up @@ -41,7 +42,10 @@ export async function fetchCopilotUsage(
});
}

const data = (await res.json()) as CopilotUsageResponse;
const data = await readProviderJsonResponse<CopilotUsageResponse>(
res,
"github-copilot-usage",
);
const windows: UsageWindow[] = [];

if (data.quota_snapshots?.premium_interactions) {
Expand Down
Loading