fix(google-media): bound JSON response reads (#96920)

* fix(google-media): bound JSON response reads

* test(google): relax media response cap assertion

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
mushuiyu886
2026-06-27 06:46:58 -07:00
committed by GitHub
co-authored by Vincent Koc
parent c5d34c8376
commit a7bfc06f45
2 changed files with 70 additions and 2 deletions
@@ -11,6 +11,7 @@ import {
import {
assertOkOrThrowProviderError,
postJsonRequest,
readProviderJsonResponse,
type ProviderRequestTransportOverrides,
} from "openclaw/plugin-sdk/provider-http";
import {
@@ -97,11 +98,11 @@ async function generateGeminiInlineDataText(params: {
try {
await assertOkOrThrowProviderError(res, params.httpErrorLabel);
const payload = (await res.json()) as {
const payload = await readProviderJsonResponse<{
candidates?: Array<{
content?: { parts?: Array<{ text?: string }> };
}>;
};
}>(res, params.httpErrorLabel);
const parts = payload.candidates?.[0]?.content?.parts ?? [];
const text = parts
.map((part) => part?.text?.trim())
@@ -1,4 +1,5 @@
// Google tests cover media understanding provider.video plugin behavior.
import { createServer, type Server } from "node:http";
import {
createRequestCaptureJsonFetch,
installPinnedHostnameTestHooks,
@@ -10,6 +11,49 @@ import { resolveGoogleGenerativeAiHttpRequestConfig } from "./runtime-api.js";
installPinnedHostnameTestHooks();
const LOOPBACK_RESPONSE_BYTES = 18 * 1024 * 1024;
async function listenLoopbackServer(server: Server): Promise<number> {
return await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("expected loopback TCP address"));
return;
}
resolve(address.port);
});
});
}
function createOversizedJsonServer(): { server: Server; closed: Promise<number> } {
let resolveClosed: (sentBytes: number) => void = () => {};
const closed = new Promise<number>((resolve) => {
resolveClosed = resolve;
});
const server = createServer((_req, res) => {
let sentBytes = 0;
const chunk = Buffer.alloc(64 * 1024, 0x20);
res.writeHead(200, { "content-type": "application/json" });
const timer = setInterval(() => {
if (sentBytes >= LOOPBACK_RESPONSE_BYTES) {
clearInterval(timer);
res.end();
return;
}
sentBytes += chunk.length;
res.write(chunk);
}, 1);
res.on("close", () => {
clearInterval(timer);
resolveClosed(sentBytes);
});
});
return { server, closed };
}
describe("describeGeminiVideo", () => {
it("respects case-insensitive x-goog-api-key overrides", async () => {
let seenKey: string | null = null;
@@ -114,6 +158,29 @@ describe("describeGeminiVideo", () => {
);
});
it("bounds oversized video JSON responses and closes the stream early", async () => {
const { server, closed } = createOversizedJsonServer();
const port = await listenLoopbackServer(server);
const fetchFn = withFetchPreconnect(async () =>
fetch(`http://127.0.0.1:${port}/google-video-json`),
);
try {
await expect(
describeGeminiVideo({
buffer: Buffer.from("video-bytes"),
fileName: "clip.mp4",
apiKey: "test-key",
timeoutMs: 1500,
fetchFn,
}),
).rejects.toThrow(/JSON response exceeds 16777216 bytes/u);
await expect(closed).resolves.toBeLessThan(LOOPBACK_RESPONSE_BYTES);
} finally {
server.close();
}
});
it("rejects non-Google video base URLs before sending authenticated requests", async () => {
await expect(
describeGeminiVideo({