Skip to content

Commit cf67d8d

Browse files
committed
fix(infra): preserve ClawHub body timeouts
1 parent 31f1ce1 commit cf67d8d

2 files changed

Lines changed: 72 additions & 25 deletions

File tree

src/infra/clawhub.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ async function expectPathMissing(targetPath: string): Promise<void> {
4040
expect((statError as { code?: unknown }).code).toBe("ENOENT");
4141
}
4242

43-
function createStalledBodyResponse(params: { headers: HeadersInit; firstChunk: Uint8Array }): {
43+
function createStalledBodyResponse(params: {
44+
headers: HeadersInit;
45+
firstChunk: Uint8Array;
46+
status?: number;
47+
statusText?: string;
48+
}): {
4449
response: Response;
4550
cancel: ReturnType<typeof vi.fn>;
4651
} {
@@ -55,7 +60,8 @@ function createStalledBodyResponse(params: { headers: HeadersInit; firstChunk: U
5560
});
5661
return {
5762
response: new Response(body, {
58-
status: 200,
63+
status: params.status ?? 200,
64+
statusText: params.statusText,
5965
headers: params.headers,
6066
}),
6167
cancel,
@@ -801,6 +807,42 @@ describe("clawhub helpers", () => {
801807
).rejects.toThrow("ClawHub /api/v1/search returned malformed JSON");
802808
});
803809

810+
it("times out and cancels stalled successful ClawHub JSON bodies", async () => {
811+
const stalled = createStalledBodyResponse({
812+
firstChunk: new TextEncoder().encode('{"results":['),
813+
headers: { "content-type": "application/json" },
814+
});
815+
816+
await expect(
817+
searchClawHubSkills({
818+
query: "calendar",
819+
timeoutMs: 5,
820+
fetchImpl: async () => stalled.response,
821+
}),
822+
).rejects.toThrow(/ClawHub \/api\/v1\/search response stalled after 5ms/);
823+
expect(stalled.cancel).toHaveBeenCalledTimes(1);
824+
expect(stalled.cancel.mock.calls[0]?.[0]).toBeInstanceOf(Error);
825+
});
826+
827+
it("times out and cancels stalled ClawHub error bodies", async () => {
828+
const stalled = createStalledBodyResponse({
829+
firstChunk: new TextEncoder().encode("partial error"),
830+
headers: { "content-type": "text/plain" },
831+
status: 500,
832+
statusText: "Server Error",
833+
});
834+
835+
await expect(
836+
searchClawHubSkills({
837+
query: "calendar",
838+
timeoutMs: 5,
839+
fetchImpl: async () => stalled.response,
840+
}),
841+
).rejects.toThrow("ClawHub /api/v1/search failed (500): Server Error");
842+
expect(stalled.cancel).toHaveBeenCalledTimes(1);
843+
expect(stalled.cancel.mock.calls[0]?.[0]).toBeInstanceOf(Error);
844+
});
845+
804846
it("bounds oversized successful ClawHub JSON responses and cancels the stream", async () => {
805847
const cancel = vi.fn();
806848
const chunk = new Uint8Array(512 * 1024).fill("x".charCodeAt(0));

src/infra/clawhub.ts

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,8 @@ const DEFAULT_CLAWHUB_URL = "https://clawhub.ai";
2323
const DEFAULT_GITHUB_CODELOAD_URL = "https://codeload.github.com";
2424
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
2525
const SKILL_CARD_MAX_BYTES = 256 * 1024;
26-
// ClawHub is an external marketplace (untrusted source): bound JSON and error
27-
// bodies so a hostile or malfunctioning host cannot exhaust memory by streaming
28-
// an unbounded response. Mirrors the error-stream hardening landed in #95108.
26+
// ClawHub is an external marketplace: bound untrusted JSON and error bodies so
27+
// a hostile or malfunctioning host cannot exhaust memory with an endless stream.
2928
const CLAWHUB_JSON_MAX_BYTES = 16 * 1024 * 1024;
3029
const CLAWHUB_ERROR_BODY_MAX_BYTES = 8 * 1024;
3130
const CLAWHUB_ERROR_BODY_MAX_CHARS = 400;
@@ -654,10 +653,7 @@ async function clawhubRequest(
654653
const timeoutMs = resolveClawHubRequestTimeoutMs(params.timeoutMs);
655654
const controller = new AbortController();
656655
const timeout = setTimeout(
657-
() =>
658-
controller.abort(
659-
new Error(`ClawHub request timed out after ${timeoutMs}ms`),
660-
),
656+
() => controller.abort(new Error(`ClawHub request timed out after ${timeoutMs}ms`)),
661657
timeoutMs,
662658
);
663659
try {
@@ -682,12 +678,12 @@ async function clawhubRequest(
682678
}
683679
}
684680

685-
async function readErrorBody(response: Response): Promise<string> {
681+
async function readErrorBody(response: Response, timeoutMs?: number): Promise<string> {
686682
try {
687683
const snippet = await readResponseTextSnippet(response, {
688684
maxBytes: CLAWHUB_ERROR_BODY_MAX_BYTES,
689685
maxChars: CLAWHUB_ERROR_BODY_MAX_CHARS,
690-
chunkTimeoutMs: DEFAULT_FETCH_TIMEOUT_MS,
686+
chunkTimeoutMs: resolveClawHubRequestTimeoutMs(timeoutMs),
691687
});
692688
return snippet || response.statusText || `HTTP ${response.status}`;
693689
} catch {
@@ -699,8 +695,9 @@ async function buildClawHubError(
699695
response: Response,
700696
url: URL,
701697
hasToken: boolean,
698+
timeoutMs?: number,
702699
): Promise<ClawHubRequestError> {
703-
let body = await readErrorBody(response);
700+
let body = await readErrorBody(response, timeoutMs);
704701
if (response.status === 429) {
705702
const suffix = formatRateLimitSuffix(response.headers, hasToken);
706703
if (suffix) {
@@ -731,14 +728,18 @@ function formatRateLimitSuffix(headers: Headers, hasToken: boolean): string {
731728
async function fetchJson<T>(params: ClawHubRequestParams): Promise<T> {
732729
const { response, url, hasToken } = await clawhubRequest(params);
733730
if (!response.ok) {
734-
throw await buildClawHubError(response, url, hasToken);
731+
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
735732
}
736-
return parseClawHubJsonBody<T>(response, url);
733+
return parseClawHubJsonBody<T>(response, url, params.timeoutMs);
737734
}
738735

739-
async function parseClawHubJsonBody<T>(response: Response, url: URL): Promise<T> {
736+
async function parseClawHubJsonBody<T>(
737+
response: Response,
738+
url: URL,
739+
timeoutMs?: number,
740+
): Promise<T> {
740741
const buffer = await readResponseWithLimit(response, CLAWHUB_JSON_MAX_BYTES, {
741-
chunkTimeoutMs: DEFAULT_FETCH_TIMEOUT_MS,
742+
chunkTimeoutMs: resolveClawHubRequestTimeoutMs(timeoutMs),
742743
onOverflow: ({ size, maxBytes }) =>
743744
new Error(
744745
`ClawHub ${url.pathname} response exceeded ${maxBytes} bytes (${size} bytes received)`,
@@ -1016,9 +1017,13 @@ export async function fetchClawHubSkillInstallResolution(params: {
10161017
});
10171018
const isStructuredBlock = [403, 409, 410, 423].includes(response.status);
10181019
if (!response.ok && !isStructuredBlock) {
1019-
throw await buildClawHubError(response, url, hasToken);
1020+
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
10201021
}
1021-
return parseClawHubJsonBody<ClawHubSkillInstallResolutionResponse>(response, url);
1022+
return parseClawHubJsonBody<ClawHubSkillInstallResolutionResponse>(
1023+
response,
1024+
url,
1025+
params.timeoutMs,
1026+
);
10221027
}
10231028

10241029
export async function fetchClawHubSkillVerification(params: {
@@ -1094,7 +1099,7 @@ export async function fetchClawHubSkillCard(params: {
10941099
skipAuth,
10951100
});
10961101
if (!response.ok) {
1097-
throw await buildClawHubError(response, url, hasToken);
1102+
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
10981103
}
10991104
const bytes = await readClawHubResponseBytes({
11001105
response,
@@ -1129,7 +1134,7 @@ export async function downloadClawHubPackageArchive(params: {
11291134
fetchImpl: params.fetchImpl,
11301135
});
11311136
if (!response.ok) {
1132-
throw await buildClawHubError(response, url, hasToken);
1137+
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
11331138
}
11341139
const bytes = await readClawHubResponseBytes({
11351140
response,
@@ -1208,7 +1213,7 @@ export async function downloadClawHubPackageArchive(params: {
12081213
fetchImpl: params.fetchImpl,
12091214
});
12101215
if (!response.ok) {
1211-
throw await buildClawHubError(response, url, hasToken);
1216+
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
12121217
}
12131218
const bytes = await readClawHubResponseBytes({
12141219
response,
@@ -1255,7 +1260,7 @@ export async function downloadClawHubSkillArchive(params: {
12551260
},
12561261
});
12571262
if (!response.ok) {
1258-
throw await buildClawHubError(response, url, hasToken);
1263+
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
12591264
}
12601265
const bytes = await readClawHubResponseBytes({
12611266
response,
@@ -1298,7 +1303,7 @@ export async function downloadClawHubSkillArchiveUrl(params: {
12981303
skipAuth,
12991304
});
13001305
if (!response.ok) {
1301-
throw await buildClawHubError(response, url, hasToken);
1306+
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
13021307
}
13031308
const bytes = await readClawHubResponseBytes({
13041309
response,
@@ -1335,7 +1340,7 @@ export async function downloadClawHubGitHubSkillArchive(params: {
13351340
fetchImpl: params.fetchImpl,
13361341
});
13371342
if (!response.ok) {
1338-
throw await buildClawHubError(response, url, hasToken);
1343+
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
13391344
}
13401345
const bytes = await readClawHubResponseBytes({
13411346
response,
@@ -1395,7 +1400,7 @@ export async function reportClawHubSkillInstallTelemetry(params: {
13951400
},
13961401
});
13971402
if (!response.ok) {
1398-
throw await buildClawHubError(response, url, hasToken);
1403+
throw await buildClawHubError(response, url, hasToken, params.timeoutMs);
13991404
}
14001405
}
14011406

0 commit comments

Comments
 (0)