diff --git a/src/infra/clawhub.test.ts b/src/infra/clawhub.test.ts index fc768ec146e..6d228d5a592 100644 --- a/src/infra/clawhub.test.ts +++ b/src/infra/clawhub.test.ts @@ -40,7 +40,12 @@ async function expectPathMissing(targetPath: string): Promise { 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; } { @@ -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)); diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index b6cc8e0d798..48d9984f97b 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -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 { +async function readErrorBody(response: Response, timeoutMs?: number): Promise { 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 { - 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(params: ClawHubRequestParams): Promise { 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(response, url); + return parseClawHubJsonBody(response, url, params.timeoutMs); } -async function parseClawHubJsonBody(response: Response, url: URL): Promise { +async function parseClawHubJsonBody( + response: Response, + url: URL, + timeoutMs?: number, +): Promise { 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(response, url); + return parseClawHubJsonBody( + 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); } }