mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user