From bde17f99e971b1036bdcd6d17ce5a78a82a5ab5a Mon Sep 17 00:00:00 2001 From: NIO Date: Sun, 19 Jul 2026 05:00:15 +0800 Subject: [PATCH] fix(mantis): bound upload error response bodies (#109044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mantis): bound upload error response bodies Replace unbounded response.text() on non-ok upload error paths with readBoundedResponseText (64 KiB cap). Only swallow the size-exceeded diagnostic; propagate signal aborts and other failures so timeouts during body reading are not masked as generic upload failures. - Import shared readBoundedResponseText from scripts/lib/bounded-response.mjs - Add MANTIS_UPLOAD_ERROR_BODY_MAX_BYTES constant (64 KiB) - Catch only size-exceeded errors; re-throw timeouts and stream errors - Add test: oversized body bounded at 64 KiB - Add test: signal abort during body read propagates correctly - Add test: small error body within bound reads normally * fix(mantis): contextualize bounded upload failures Co-authored-by: 胡根深 0668000903 --------- Co-authored-by: Peter Steinberger --- scripts/mantis/publish-pr-evidence.mjs | 30 +++-- .../mantis-publish-pr-evidence.test.ts | 109 ++++++++++++++++++ 2 files changed, 130 insertions(+), 9 deletions(-) diff --git a/scripts/mantis/publish-pr-evidence.mjs b/scripts/mantis/publish-pr-evidence.mjs index 65074ed2065a..e2c286e5c590 100644 --- a/scripts/mantis/publish-pr-evidence.mjs +++ b/scripts/mantis/publish-pr-evidence.mjs @@ -6,9 +6,12 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { readBoundedResponseText } from "../lib/bounded-response.mjs"; // Evidence bundles can include full videos, so allow slow transfers while bounding each PUT. const MANTIS_ARTIFACT_UPLOAD_TIMEOUT_MS = 300_000; +// Untrusted storage error bodies are for diagnostics only; keep them small. +const MANTIS_UPLOAD_ERROR_BODY_MAX_BYTES = 64 * 1024; function parseArgs(argv) { const args = {}; @@ -441,14 +444,30 @@ function run(command, args, options = {}) { async function uploadArtifact({ artifact, fetchImpl, request, timeoutMs }) { const signal = AbortSignal.timeout(timeoutMs); - let response; try { - response = await fetchImpl(request.url, { + const response = await fetchImpl(request.url, { body: request.body, headers: request.headers, method: request.method, signal, }); + if (response.ok) { + await response.body?.cancel().catch(() => undefined); + return; + } + + const failurePrefix = `Failed to upload Mantis artifact ${artifact.targetPath}: ${response.status} ${response.statusText}`; + const responseText = await readBoundedResponseText( + response, + "Mantis upload error", + MANTIS_UPLOAD_ERROR_BODY_MAX_BYTES, + { + signal, + formatTooLargeMessage: (_label, maxBytes) => + `${failurePrefix}\nMantis upload error response body exceeded ${maxBytes} bytes`, + }, + ); + throw new Error(`${failurePrefix}\n${responseText}`); } catch (error) { if (signal.aborted) { throw new Error( @@ -458,13 +477,6 @@ async function uploadArtifact({ artifact, fetchImpl, request, timeoutMs }) { } throw error; } - if (response.ok) { - return; - } - const responseText = await response.text(); - throw new Error( - `Failed to upload Mantis artifact ${artifact.targetPath}: ${response.status} ${response.statusText}\n${responseText}`, - ); } export async function publishArtifactFiles({ diff --git a/test/scripts/mantis-publish-pr-evidence.test.ts b/test/scripts/mantis-publish-pr-evidence.test.ts index 0c58b2e06641..4c210b709a69 100644 --- a/test/scripts/mantis-publish-pr-evidence.test.ts +++ b/test/scripts/mantis-publish-pr-evidence.test.ts @@ -200,6 +200,115 @@ describe("scripts/mantis/publish-pr-evidence", () => { expect(observedSignal?.aborted).toBe(true); }); + it("bounds oversized non-ok upload error response bodies", async () => { + const manifest = loadEvidenceManifest(writeFixtureManifest()); + const chunk = new Uint8Array(8 * 1024).fill("x".charCodeAt(0)); + let enqueuedBytes = 0; + const body = new ReadableStream({ + pull(controller) { + enqueuedBytes += chunk.byteLength; + controller.enqueue(chunk); + }, + }); + + const upload = publishArtifactFiles({ + artifactRoot: "mantis/discord/pr-1/run-1", + fetchImpl: async () => + new Response(body, { + status: 503, + statusText: "Service Unavailable", + }), + manifest, + storageConfig: { + accessKeyId: "access", + bucket: "qa-artifacts", + endpoint: "https://example.r2.cloudflarestorage.com", + publicBaseUrl: "https://qa.openclaw.ai", + region: "auto", + secretAccessKey: "secret", + }, + }); + + await expect(upload).rejects.toMatchObject({ + message: expect.stringMatching( + /^Failed to upload Mantis artifact baseline\.png: 503 Service Unavailable\nMantis upload error response body exceeded 65536 bytes$/u, + ), + }); + // Unbounded response.text() would keep pulling forever; the bound cancels after ~64 KiB. + expect(enqueuedBytes).toBeGreaterThan(64 * 1024); + expect(enqueuedBytes).toBeLessThanOrEqual(256 * 1024); + }); + + it("propagates signal abort during non-ok upload error body reading", async () => { + const manifest = loadEvidenceManifest(writeFixtureManifest()); + let cancelled = false; + // A slow-streaming error body that will stall until the signal fires. + const body = new ReadableStream({ + pull() { + // Never resolves; the signal will abort the read. + return new Promise(() => {}); + }, + cancel() { + cancelled = true; + }, + }); + + const upload = publishArtifactFiles({ + artifactRoot: "mantis/discord/pr-1/run-1", + fetchImpl: async () => + new Response(body, { + status: 503, + statusText: "Service Unavailable", + }), + manifest, + storageConfig: { + accessKeyId: "access", + bucket: "qa-artifacts", + endpoint: "https://example.r2.cloudflarestorage.com", + publicBaseUrl: "https://qa.openclaw.ai", + region: "auto", + secretAccessKey: "secret", + }, + timeoutMs: 50, + }); + + await expect(upload).rejects.toMatchObject({ + cause: { name: "TimeoutError" }, + message: "Timed out uploading Mantis artifact baseline.png after 50ms.", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(cancelled).toBe(true); + }); + + it("reads small non-ok upload error response bodies within the bound", async () => { + const manifest = loadEvidenceManifest(writeFixtureManifest()); + const smallBody = "access denied: invalid credentials"; + + const upload = publishArtifactFiles({ + artifactRoot: "mantis/discord/pr-1/run-1", + fetchImpl: async () => + new Response(smallBody, { + status: 403, + statusText: "Forbidden", + }), + manifest, + storageConfig: { + accessKeyId: "access", + bucket: "qa-artifacts", + endpoint: "https://example.r2.cloudflarestorage.com", + publicBaseUrl: "https://qa.openclaw.ai", + region: "auto", + secretAccessKey: "secret", + }, + }); + + await expect(upload).rejects.toMatchObject({ + message: expect.stringMatching( + /^Failed to upload Mantis artifact baseline\.png: 403 Forbidden\naccess denied: invalid credentials$/u, + ), + }); + }); + it("allows failure manifests to omit optional visual artifacts", () => { const dir = mkdtempSync(path.join(tmpdir(), "mantis-evidence-test-")); tempDirs.push(dir);