fix(infra): preserve ClawHub body timeouts

This commit is contained in:
Peter Steinberger
2026-06-23 13:13:32 -07:00
parent 31f1ce1af6
commit cf67d8dded
2 changed files with 72 additions and 25 deletions

View File

@@ -40,7 +40,12 @@ async function expectPathMissing(targetPath: string): Promise<void> {
expect((statError as { code?: unknown }).code).toBe("ENOENT");
}
function createStalledBodyResponse(params: { headers: HeadersInit; firstChunk: Uint8Array }): {
function createStalledBodyResponse(params: {
headers: HeadersInit;
firstChunk: Uint8Array;
status?: number;
statusText?: string;
}): {
response: Response;
cancel: ReturnType<typeof vi.fn>;
} {
@@ -55,7 +60,8 @@ function createStalledBodyResponse(params: { headers: HeadersInit; firstChunk: U
});
return {
response: new Response(body, {
status: 200,
status: params.status ?? 200,
statusText: params.statusText,
headers: params.headers,
}),
cancel,
@@ -801,6 +807,42 @@ describe("clawhub helpers", () => {
).rejects.toThrow("ClawHub /api/v1/search returned malformed JSON");
});
it("times out and cancels stalled successful ClawHub JSON bodies", async () => {
const stalled = createStalledBodyResponse({
firstChunk: new TextEncoder().encode('{"results":['),
headers: { "content-type": "application/json" },
});
await expect(
searchClawHubSkills({
query: "calendar",
timeoutMs: 5,
fetchImpl: async () => stalled.response,
}),
).rejects.toThrow(/ClawHub \/api\/v1\/search response stalled after 5ms/);
expect(stalled.cancel).toHaveBeenCalledTimes(1);
expect(stalled.cancel.mock.calls[0]?.[0]).toBeInstanceOf(Error);
});
it("times out and cancels stalled ClawHub error bodies", async () => {
const stalled = createStalledBodyResponse({
firstChunk: new TextEncoder().encode("partial error"),
headers: { "content-type": "text/plain" },
status: 500,
statusText: "Server Error",
});
await expect(
searchClawHubSkills({
query: "calendar",
timeoutMs: 5,
fetchImpl: async () => stalled.response,
}),
).rejects.toThrow("ClawHub /api/v1/search failed (500): Server Error");
expect(stalled.cancel).toHaveBeenCalledTimes(1);
expect(stalled.cancel.mock.calls[0]?.[0]).toBeInstanceOf(Error);
});
it("bounds oversized successful ClawHub JSON responses and cancels the stream", async () => {
const cancel = vi.fn();
const chunk = new Uint8Array(512 * 1024).fill("x".charCodeAt(0));

View File

@@ -23,9 +23,8 @@ const DEFAULT_CLAWHUB_URL = "https://clawhub.ai";
const DEFAULT_GITHUB_CODELOAD_URL = "https://codeload.github.com";
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
const SKILL_CARD_MAX_BYTES = 256 * 1024;
// ClawHub is an external marketplace (untrusted source): bound JSON and error
// bodies so a hostile or malfunctioning host cannot exhaust memory by streaming
// an unbounded response. Mirrors the error-stream hardening landed in #95108.
// ClawHub is an external marketplace: bound untrusted JSON and error bodies so
// a hostile or malfunctioning host cannot exhaust memory with an endless stream.
const CLAWHUB_JSON_MAX_BYTES = 16 * 1024 * 1024;
const CLAWHUB_ERROR_BODY_MAX_BYTES = 8 * 1024;
const CLAWHUB_ERROR_BODY_MAX_CHARS = 400;
@@ -654,10 +653,7 @@ async function clawhubRequest(
const timeoutMs = resolveClawHubRequestTimeoutMs(params.timeoutMs);
const controller = new AbortController();
const timeout = setTimeout(
() =>
controller.abort(
new Error(`ClawHub request timed out after ${timeoutMs}ms`),
),
() => controller.abort(new Error(`ClawHub request timed out after ${timeoutMs}ms`)),
timeoutMs,
);
try {
@@ -682,12 +678,12 @@ async function clawhubRequest(
}
}
async function readErrorBody(response: Response): Promise<string> {
async function readErrorBody(response: Response, timeoutMs?: number): Promise<string> {
try {
const snippet = await readResponseTextSnippet(response, {
maxBytes: CLAWHUB_ERROR_BODY_MAX_BYTES,
maxChars: CLAWHUB_ERROR_BODY_MAX_CHARS,
chunkTimeoutMs: DEFAULT_FETCH_TIMEOUT_MS,
chunkTimeoutMs: resolveClawHubRequestTimeoutMs(timeoutMs),
});
return snippet || response.statusText || `HTTP ${response.status}`;
} catch {
@@ -699,8 +695,9 @@ async function buildClawHubError(
response: Response,
url: URL,
hasToken: boolean,
timeoutMs?: number,
): Promise<ClawHubRequestError> {
let body = await readErrorBody(response);
let body = await readErrorBody(response, timeoutMs);
if (response.status === 429) {
const suffix = formatRateLimitSuffix(response.headers, hasToken);
if (suffix) {
@@ -731,14 +728,18 @@ function formatRateLimitSuffix(headers: Headers, hasToken: boolean): string {
async function fetchJson<T>(params: ClawHubRequestParams): Promise<T> {
const { response, url, hasToken } = await clawhubRequest(params);
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
}
return parseClawHubJsonBody<T>(response, url);
return parseClawHubJsonBody<T>(response, url, params.timeoutMs);
}
async function parseClawHubJsonBody<T>(response: Response, url: URL): Promise<T> {
async function parseClawHubJsonBody<T>(
response: Response,
url: URL,
timeoutMs?: number,
): Promise<T> {
const buffer = await readResponseWithLimit(response, CLAWHUB_JSON_MAX_BYTES, {
chunkTimeoutMs: DEFAULT_FETCH_TIMEOUT_MS,
chunkTimeoutMs: resolveClawHubRequestTimeoutMs(timeoutMs),
onOverflow: ({ size, maxBytes }) =>
new Error(
`ClawHub ${url.pathname} response exceeded ${maxBytes} bytes (${size} bytes received)`,
@@ -1016,9 +1017,13 @@ export async function fetchClawHubSkillInstallResolution(params: {
});
const isStructuredBlock = [403, 409, 410, 423].includes(response.status);
if (!response.ok && !isStructuredBlock) {
throw await buildClawHubError(response, url, hasToken);
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
}
return parseClawHubJsonBody<ClawHubSkillInstallResolutionResponse>(response, url);
return parseClawHubJsonBody<ClawHubSkillInstallResolutionResponse>(
response,
url,
params.timeoutMs,
);
}
export async function fetchClawHubSkillVerification(params: {
@@ -1094,7 +1099,7 @@ export async function fetchClawHubSkillCard(params: {
skipAuth,
});
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
}
const bytes = await readClawHubResponseBytes({
response,
@@ -1129,7 +1134,7 @@ export async function downloadClawHubPackageArchive(params: {
fetchImpl: params.fetchImpl,
});
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
}
const bytes = await readClawHubResponseBytes({
response,
@@ -1208,7 +1213,7 @@ export async function downloadClawHubPackageArchive(params: {
fetchImpl: params.fetchImpl,
});
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
}
const bytes = await readClawHubResponseBytes({
response,
@@ -1255,7 +1260,7 @@ export async function downloadClawHubSkillArchive(params: {
},
});
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
}
const bytes = await readClawHubResponseBytes({
response,
@@ -1298,7 +1303,7 @@ export async function downloadClawHubSkillArchiveUrl(params: {
skipAuth,
});
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
}
const bytes = await readClawHubResponseBytes({
response,
@@ -1335,7 +1340,7 @@ export async function downloadClawHubGitHubSkillArchive(params: {
fetchImpl: params.fetchImpl,
});
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
}
const bytes = await readClawHubResponseBytes({
response,
@@ -1395,7 +1400,7 @@ export async function reportClawHubSkillInstallTelemetry(params: {
},
});
if (!response.ok) {
throw await buildClawHubError(response, url, hasToken);
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
}
}