From 4d37f42df7f21039e95eb07b4de7228eb32cdcc0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 19 Jun 2026 14:17:01 +0200 Subject: [PATCH] fix(github-copilot): bound embedding error bodies --- extensions/github-copilot/embeddings.test.ts | 80 ++++++++++++++++++++ extensions/github-copilot/embeddings.ts | 12 +-- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/extensions/github-copilot/embeddings.test.ts b/extensions/github-copilot/embeddings.test.ts index c76df3e67a7..4db618a5814 100644 --- a/extensions/github-copilot/embeddings.test.ts +++ b/extensions/github-copilot/embeddings.test.ts @@ -47,6 +47,28 @@ function buildModelsResponse(models: Array<{ id: string; supported_endpoints?: u return { data: models }; } +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 mockDiscoveryResponse(spec: { ok: boolean; status?: number; @@ -116,6 +138,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); resolveConfiguredSecretInputStringMock.mockReset(); resolveFirstGithubTokenMock.mockReset(); resolveCopilotApiTokenMock.mockReset(); @@ -221,6 +244,63 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => { ).rejects.toThrow("GitHub Copilot model discovery returned invalid JSON"); }); + it("bounds model discovery error bodies", async () => { + const tracked = cancelTrackedResponse(`${"discovery denied ".repeat(1024)}tail`, { + status: 503, + headers: { "content-type": "text/plain" }, + }); + const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); + fetchWithSsrFGuardMock.mockImplementationOnce(async () => ({ + response: tracked.response, + release: vi.fn(async () => {}), + })); + + let caught: Error | undefined; + try { + await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()); + } catch (error) { + caught = error as Error; + } + + expect(caught?.message).toContain("GitHub Copilot model discovery HTTP 503"); + expect(caught?.message).toContain("discovery denied"); + expect(caught?.message).not.toContain("tail"); + expect(caught?.message.length).toBeLessThan(8_300); + expect(tracked.wasCanceled()).toBe(true); + expect(textSpy).not.toHaveBeenCalled(); + }); + + it("bounds embeddings error bodies", async () => { + mockDiscoveryResponse({ + ok: true, + json: buildModelsResponse([ + { id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] }, + ]), + }); + const tracked = cancelTrackedResponse(`${"embedding denied ".repeat(1024)}tail`, { + status: 429, + headers: { "content-type": "text/plain" }, + }); + const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); + const fetchImpl = vi.fn(async () => tracked.response); + vi.stubGlobal("fetch", fetchImpl); + const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()); + + let caught: Error | undefined; + try { + await result.provider?.embedQuery("hello"); + } catch (error) { + caught = error as Error; + } + + expect(caught?.message).toContain("GitHub Copilot embeddings HTTP 429"); + expect(caught?.message).toContain("embedding denied"); + expect(caught?.message).not.toContain("tail"); + expect(caught?.message.length).toBeLessThan(8_300); + expect(tracked.wasCanceled()).toBe(true); + expect(textSpy).not.toHaveBeenCalled(); + }); + it("honors remote overrides when creating the provider", async () => { resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "gh_remote_token" }); mockDiscoveryResponse({ diff --git a/extensions/github-copilot/embeddings.ts b/extensions/github-copilot/embeddings.ts index 1a6f4456a17..e682dfac22c 100644 --- a/extensions/github-copilot/embeddings.ts +++ b/extensions/github-copilot/embeddings.ts @@ -7,6 +7,7 @@ import { type MemoryEmbeddingProviderAdapter, } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth"; +import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolveFirstGithubToken } from "./auth.js"; @@ -27,6 +28,7 @@ const COPILOT_HEADERS_STATIC: Record = { "Content-Type": "application/json", ...buildCopilotIdeHeaders(), }; +const COPILOT_ERROR_BODY_LIMIT_BYTES = 8 * 1024; function buildSsrfPolicy(baseUrl: string): SsrFPolicy | undefined { try { @@ -95,9 +97,8 @@ async function discoverEmbeddingModels(params: { }); try { if (!response.ok) { - throw new Error( - `GitHub Copilot model discovery HTTP ${response.status}: ${await response.text()}`, - ); + const detail = await readResponseTextLimited(response, COPILOT_ERROR_BODY_LIMIT_BYTES); + throw new Error(`GitHub Copilot model discovery HTTP ${response.status}: ${detail}`); } let payload: unknown; try { @@ -241,9 +242,8 @@ async function createGitHubCopilotEmbeddingProvider( }, onResponse: async (response) => { if (!response.ok) { - throw new Error( - `GitHub Copilot embeddings HTTP ${response.status}: ${await response.text()}`, - ); + const detail = await readResponseTextLimited(response, COPILOT_ERROR_BODY_LIMIT_BYTES); + throw new Error(`GitHub Copilot embeddings HTTP ${response.status}: ${detail}`); } let payload: unknown;