From 6c73ffc51a614b48775cf698cf8ad64000dfd09b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 1 Jun 2026 04:00:13 +0200 Subject: [PATCH] fix(test): bound MCP code mode client responses --- scripts/e2e/mcp-code-mode-gateway-client.ts | 87 ++++++++++++++++--- .../mcp-code-mode-gateway-client.test.ts | 86 ++++++++++++++++++ 2 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 test/scripts/mcp-code-mode-gateway-client.test.ts diff --git a/scripts/e2e/mcp-code-mode-gateway-client.ts b/scripts/e2e/mcp-code-mode-gateway-client.ts index 7e3e6a7585c..7c2b3c70a8b 100644 --- a/scripts/e2e/mcp-code-mode-gateway-client.ts +++ b/scripts/e2e/mcp-code-mode-gateway-client.ts @@ -1,6 +1,35 @@ import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as setNodeTimeout, clearTimeout as clearNodeTimeout } from "node:timers"; +import { pathToFileURL } from "node:url"; +import { readBoundedResponseText } from "../lib/bounded-response.ts"; +import { readPositiveIntEnv } from "./lib/env-limits.mjs"; + +type FetchJsonOptions = { + fetchImpl?: (url: string, init: RequestInit) => Promise; + maxBodyBytes?: number; + timeoutMs?: number; +}; + +export type McpCodeModeClientFetchLimits = { + bodyMaxBytes: number; + timeoutMs: number; +}; + +export function readMcpCodeModeClientFetchLimits( + env: NodeJS.ProcessEnv = process.env, +): McpCodeModeClientFetchLimits { + return { + bodyMaxBytes: readPositiveIntEnv( + "OPENCLAW_MCP_CODE_MODE_CLIENT_BODY_MAX_BYTES", + 1024 * 1024, + env, + ), + timeoutMs: readPositiveIntEnv("OPENCLAW_MCP_CODE_MODE_CLIENT_TIMEOUT_MS", 300_000, env), + }; +} + +const DEFAULT_FETCH_LIMITS = readMcpCodeModeClientFetchLimits(); function assert(condition: unknown, message: string): asserts condition { if (!condition) { @@ -8,23 +37,59 @@ function assert(condition: unknown, message: string): asserts condition { } } -async function fetchJson(url: string, init: RequestInit = {}): Promise { - const timeoutMs = Number(process.env.OPENCLAW_MCP_CODE_MODE_CLIENT_TIMEOUT_MS ?? 300_000); +function taggedError(message: string, code: string) { + return Object.assign(new Error(message), { code }); +} + +export async function fetchJson( + url: string, + init: RequestInit = {}, + options: FetchJsonOptions = {}, +): Promise { + const timeoutMs = Math.max(1, options.timeoutMs ?? DEFAULT_FETCH_LIMITS.timeoutMs); + const maxBodyBytes = Math.max(1, options.maxBodyBytes ?? DEFAULT_FETCH_LIMITS.bodyMaxBytes); const controller = new AbortController(); + const timeoutError = taggedError( + `HTTP request to ${url} timed out after ${timeoutMs}ms`, + "ETIMEDOUT", + ); let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeout = setNodeTimeout(() => { + controller.abort(timeoutError); + reject(timeoutError); + }, timeoutMs); + timeout.unref?.(); + }); + let response: Response | undefined; + let text = ""; try { - timeout = setNodeTimeout(() => controller.abort(), timeoutMs); - const response = await fetch(url, { ...init, signal: controller.signal }); - const text = await response.text(); - if (!response.ok) { - throw new Error(`HTTP ${response.status} from ${url}: ${text}`); - } - return text ? JSON.parse(text) : {}; + response = await Promise.race([ + (options.fetchImpl ?? fetch)(url, { ...init, signal: controller.signal }), + timeoutPromise, + ]); + text = await readBoundedResponseText(response, url, maxBodyBytes, { + createTooLargeError(message) { + return taggedError(message, "ETOOBIG"); + }, + formatTooLargeMessage(targetUrl, byteLimit) { + return `HTTP response from ${targetUrl} exceeded ${byteLimit} bytes`; + }, + signal: controller.signal, + timeoutPromise, + }); } finally { if (timeout) { clearNodeTimeout(timeout); } } + if (!response) { + throw new Error(`HTTP request to ${url} did not return a response`); + } + if (!response.ok) { + throw new Error(`HTTP ${response.status} from ${url}: ${text}`); + } + return text ? JSON.parse(text) : {}; } function outputText(response: unknown): string { @@ -169,4 +234,6 @@ async function main() { ); } -await main(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/test/scripts/mcp-code-mode-gateway-client.test.ts b/test/scripts/mcp-code-mode-gateway-client.test.ts new file mode 100644 index 00000000000..bff020f942c --- /dev/null +++ b/test/scripts/mcp-code-mode-gateway-client.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + fetchJson, + readMcpCodeModeClientFetchLimits, +} from "../../scripts/e2e/mcp-code-mode-gateway-client.ts"; + +describe("MCP code-mode gateway Docker client fetch helper", () => { + it("rejects loose numeric env limits instead of parsing prefixes", () => { + expect(() => + readMcpCodeModeClientFetchLimits({ + OPENCLAW_MCP_CODE_MODE_CLIENT_TIMEOUT_MS: "1e3", + }), + ).toThrow("invalid OPENCLAW_MCP_CODE_MODE_CLIENT_TIMEOUT_MS: 1e3"); + expect(() => + readMcpCodeModeClientFetchLimits({ + OPENCLAW_MCP_CODE_MODE_CLIENT_BODY_MAX_BYTES: "1000ms", + }), + ).toThrow("invalid OPENCLAW_MCP_CODE_MODE_CLIENT_BODY_MAX_BYTES: 1000ms"); + expect( + readMcpCodeModeClientFetchLimits({ + OPENCLAW_MCP_CODE_MODE_CLIENT_BODY_MAX_BYTES: "4096", + OPENCLAW_MCP_CODE_MODE_CLIENT_TIMEOUT_MS: "120000", + }), + ).toEqual({ + bodyMaxBytes: 4096, + timeoutMs: 120_000, + }); + }); + + it("aborts requests that never resolve", async () => { + let signal: AbortSignal | undefined; + await expect( + fetchJson("https://qa.example.invalid/v1/responses", undefined, { + timeoutMs: 25, + fetchImpl: async (_url, init) => { + signal = init.signal as AbortSignal | undefined; + return new Promise(() => {}); + }, + }), + ).rejects.toMatchObject({ + code: "ETIMEDOUT", + message: "HTTP request to https://qa.example.invalid/v1/responses timed out after 25ms", + }); + expect(signal?.aborted).toBe(true); + }); + + it("times out while reading stalled response bodies", async () => { + await expect( + fetchJson("https://qa.example.invalid/v1/responses", undefined, { + timeoutMs: 25, + fetchImpl: async () => + new Response(new ReadableStream({ start() {} }), { + status: 200, + }), + }), + ).rejects.toMatchObject({ + code: "ETIMEDOUT", + message: "HTTP request to https://qa.example.invalid/v1/responses timed out after 25ms", + }); + }); + + it("parses successful JSON responses", async () => { + await expect( + fetchJson("https://qa.example.invalid/v1/responses", undefined, { + timeoutMs: 25, + fetchImpl: async () => new Response('{"ok":true}', { status: 200 }), + }), + ).resolves.toEqual({ ok: true }); + }); + + it("bounds oversized response bodies", async () => { + await expect( + fetchJson("https://qa.example.invalid/v1/responses", undefined, { + maxBodyBytes: 16, + timeoutMs: 1000, + fetchImpl: async () => + new Response(JSON.stringify({ ok: true, padding: "x".repeat(128) }), { + status: 200, + }), + }), + ).rejects.toMatchObject({ + code: "ETOOBIG", + message: "HTTP response from https://qa.example.invalid/v1/responses exceeded 16 bytes", + }); + }); +});