diff --git a/extensions/copilot/src/byok-proxy.test.ts b/extensions/copilot/src/byok-proxy.test.ts index 147e995ac818..cbf042c3b599 100644 --- a/extensions/copilot/src/byok-proxy.test.ts +++ b/extensions/copilot/src/byok-proxy.test.ts @@ -15,6 +15,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => ({ describe("createCopilotByokProxy", () => { afterEach(() => { ssrfRuntimeMock.fetchWithSsrFGuard.mockReset(); + vi.restoreAllMocks(); }); it("presents a loopback SDK endpoint and forwards through guarded fetch", async () => { @@ -82,6 +83,77 @@ describe("createCopilotByokProxy", () => { } }); + it.each([307, 308])("preserves binary request bytes across a %i redirect", async (status) => { + const { fetchWithSsrFGuard } = await vi.importActual< + typeof import("openclaw/plugin-sdk/ssrf-runtime") + >("openclaw/plugin-sdk/ssrf-runtime"); + ssrfRuntimeMock.fetchWithSsrFGuard.mockImplementation(fetchWithSsrFGuard); + const clientFetch = globalThis.fetch; + const received: Buffer[] = []; + const upstreamFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + const request = new Request(url, init); + expect(request.method).toBe("POST"); + received.push(Buffer.from(await request.arrayBuffer())); + return received.length === 1 + ? new Response(null, { status, headers: { location: "/v1/replayed" } }) + : new Response("ok"); + }); + const proxy = await createCopilotByokProxy( + resolveCopilotProvider({ + model: { + provider: "custom-proxy", + api: "openai-responses", + id: "proxy-model", + baseUrl: "https://proxy.example/v1", + }, + }), + ); + // Keep invalid UTF-8 and a nonzero offset: forwarding the backing pool would leak other bytes. + const body = Buffer.from([42, 0, 255, 128, 192, 10, 42]).subarray(1, -1); + try { + const response = await clientFetch(`${proxy?.provider.provider?.baseUrl}/responses`, { + method: "POST", + body, + }); + expect(response.status).toBe(200); + expect(await response.text()).toBe("ok"); + expect(upstreamFetch).toHaveBeenCalledTimes(2); + expect(received).toEqual([body, body]); + } finally { + await proxy?.close(); + } + }); + + it.each(["GET", "HEAD", "POST"])( + "forwards an empty %s request without a body", + async (method) => { + ssrfRuntimeMock.fetchWithSsrFGuard.mockResolvedValue({ + response: new Response(null, { status: 204 }), + release: vi.fn(async () => undefined), + }); + const proxy = await createCopilotByokProxy( + resolveCopilotProvider({ + model: { + provider: "custom-proxy", + api: "openai-responses", + id: "proxy-model", + baseUrl: "https://proxy.example/v1", + }, + }), + ); + try { + const response = await fetch(`${proxy?.provider.provider?.baseUrl}/responses`, { method }); + expect(response.status).toBe(204); + expect(ssrfRuntimeMock.fetchWithSsrFGuard).toHaveBeenCalledWith( + expect.objectContaining({ init: expect.objectContaining({ method }) }), + ); + expect(ssrfRuntimeMock.fetchWithSsrFGuard.mock.calls[0]?.[0].init.body).toBeUndefined(); + } finally { + await proxy?.close(); + } + }, + ); + it("injects resolved bearer auth when the SDK request omits Authorization", async () => { ssrfRuntimeMock.fetchWithSsrFGuard.mockResolvedValue({ response: new Response("ok", { status: 200 }), diff --git a/extensions/copilot/src/byok-proxy.ts b/extensions/copilot/src/byok-proxy.ts index 17c1cb844b5c..f28f679eba74 100644 --- a/extensions/copilot/src/byok-proxy.ts +++ b/extensions/copilot/src/byok-proxy.ts @@ -126,7 +126,7 @@ async function handleProxyRequest( : undefined, }), signal: upstreamAbort.signal, - ...(body ? { body: toFetchBody(body) } : {}), + ...(body ? { body } : {}), }, auditContext: "copilot-byok-provider", requireHttps: true, @@ -210,7 +210,7 @@ function isNonceProtectedProxyRequest(req: IncomingMessage, proxyPathPrefix: str ); } -async function readBody(req: IncomingMessage): Promise { +async function readBody(req: IncomingMessage): Promise | undefined> { const chunks: Buffer[] = []; for await (const chunk of req) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); @@ -218,12 +218,6 @@ async function readBody(req: IncomingMessage): Promise { return chunks.length > 0 ? Buffer.concat(chunks) : undefined; } -function toFetchBody(body: Buffer): Uint8Array { - const copy = new Uint8Array(body.byteLength); - copy.set(body); - return copy; -} - function normalizeProxyRequestHeaders(headers: IncomingMessage["headers"]): Record { const out: Record = {}; for (const [key, value] of Object.entries(headers)) {