fix(mantis): bound upload error response bodies (#109044)

* 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 <hu.genshen@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
NIO
2026-07-19 05:00:15 +08:00
committed by GitHub
parent ff3ed99cde
commit bde17f99e9
2 changed files with 130 additions and 9 deletions
+21 -9
View File
@@ -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({
@@ -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<Uint8Array>({
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<Uint8Array>({
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);