From 605e2976ed68b75346758cc81a8917ded87ff01a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 28 May 2026 21:31:38 +0200 Subject: [PATCH] fix(e2e): bound release fixture response bodies --- .../lib/release-user-journey/assertions.mjs | 61 ++++++++++++++++++- .../release-user-journey-assertions.test.ts | 39 ++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/scripts/e2e/lib/release-user-journey/assertions.mjs b/scripts/e2e/lib/release-user-journey/assertions.mjs index 61f83965336..c7e8281bcc7 100644 --- a/scripts/e2e/lib/release-user-journey/assertions.mjs +++ b/scripts/e2e/lib/release-user-journey/assertions.mjs @@ -11,6 +11,10 @@ const CLICKCLACK_HTTP_TIMEOUT_MS = readPositiveInt( process.env.OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS, 5000, ); +const CLICKCLACK_HTTP_BODY_MAX_BYTES = readPositiveInt( + process.env.OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES, + 1024 * 1024, +); function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); @@ -38,6 +42,55 @@ async function withClickClackFixtureResponse(url, init, consume, options = {}) { } } +function bodyTooLargeError(label, byteLimit) { + return Object.assign(new Error(`${label} response body exceeded ${byteLimit} bytes`), { + code: "ETOOBIG", + }); +} + +async function readBoundedResponseText( + response, + label, + byteLimit = CLICKCLACK_HTTP_BODY_MAX_BYTES, +) { + const contentLength = response.headers.get("content-length"); + if (contentLength) { + const parsedLength = Number(contentLength); + if (Number.isSafeInteger(parsedLength) && parsedLength > byteLimit) { + await response.body?.cancel().catch(() => {}); + throw bodyTooLargeError(label, byteLimit); + } + } + if (!response.body) { + return ""; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let byteCount = 0; + let text = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + return text + decoder.decode(); + } + byteCount += value.byteLength; + if (byteCount > byteLimit) { + await reader.cancel().catch(() => {}); + throw bodyTooLargeError(label, byteLimit); + } + text += decoder.decode(value, { stream: true }); + } + } finally { + reader.releaseLock(); + } +} + +async function readBoundedResponseJson(response, label) { + return JSON.parse(await readBoundedResponseText(response, label)); +} + function resolveHomePath(value) { if (value === "~") { return process.env.HOME; @@ -242,7 +295,8 @@ async function postClickClackInbound() { body: JSON.stringify({ body }), }, async (response) => { - assert(response.ok, `fixture inbound failed: ${response.status} ${await response.text()}`); + const text = response.ok ? "" : await readBoundedResponseText(response, "ClickClack inbound"); + assert(response.ok, `fixture inbound failed: ${response.status} ${text}`); }, ); } @@ -256,7 +310,10 @@ async function waitClickClackSocket() { const state = await withClickClackFixtureResponse( `${baseUrl}/fixture/state`, {}, - async (response) => (response.ok ? await response.json() : undefined), + async (response) => + response.ok + ? await readBoundedResponseJson(response, "ClickClack fixture state") + : undefined, { timeoutMs: Math.min(CLICKCLACK_HTTP_TIMEOUT_MS, remainingMs), }, diff --git a/test/scripts/release-user-journey-assertions.test.ts b/test/scripts/release-user-journey-assertions.test.ts index ab08cd2f8bd..a630f5707bb 100644 --- a/test/scripts/release-user-journey-assertions.test.ts +++ b/test/scripts/release-user-journey-assertions.test.ts @@ -244,4 +244,43 @@ describe("release user journey assertions", () => { rmSync(root, { force: true, recursive: true }); } }); + + it("bounds ClickClack fixture error response bodies", async () => { + const root = mkdtempSync(path.join(tmpdir(), "openclaw-release-user-assertions-")); + const home = path.join(root, "home"); + const portPath = path.join(root, "port.txt"); + const server = startTcpFixture( + portPath, + [ + "(socket) => {", + ' const body = "x".repeat(128);', + " socket.end(`HTTP/1.1 500 Internal Server Error\\r\\nContent-Type: text/plain\\r\\nContent-Length: ${Buffer.byteLength(body)}\\r\\n\\r\\n${body}`);", + "}", + ].join("\n"), + ); + + try { + const port = Number.parseInt((await waitForFile(portPath)).trim(), 10); + const result = runAssertion( + home, + ["post-clickclack-inbound", `http://127.0.0.1:${port}`, "hello"], + { + env: { + OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES: "16", + OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS: "1000", + }, + timeoutMs: 2500, + }, + ); + + expect(result.error).toBeUndefined(); + expect(result.signal).not.toBe("SIGKILL"); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("ClickClack inbound response body exceeded 16 bytes"); + expect(result.stderr).not.toContain("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); + } finally { + await stopChild(server); + rmSync(root, { force: true, recursive: true }); + } + }); });