Skip to content

Commit 211e80e

Browse files
committed
fix(plugin-sdk): bound live model catalog success body
1 parent e451a4e commit 211e80e

2 files changed

Lines changed: 111 additions & 2 deletions

File tree

src/plugin-sdk/provider-catalog-live-runtime.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,91 @@ describe("provider-catalog-live-runtime", () => {
157157
expect(fetchGuardMock).toHaveBeenCalledTimes(1);
158158
});
159159

160+
it("bounds an unbounded live catalog success stream and cancels the body", async () => {
161+
const encoder = new TextEncoder();
162+
let pullCount = 0;
163+
let cancelled = false;
164+
const release = vi.fn(async () => undefined);
165+
const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi.fn(async () => ({
166+
response: new Response(
167+
new ReadableStream<Uint8Array>({
168+
pull(controller) {
169+
pullCount += 1;
170+
// Stream a JSON array prefix followed by an effectively endless run of
171+
// padding so the body never terminates under its own power.
172+
if (pullCount === 1) {
173+
controller.enqueue(encoder.encode('[{"id":"model-a","object":"model"},'));
174+
return;
175+
}
176+
controller.enqueue(encoder.encode("0".repeat(1024 * 1024)));
177+
},
178+
cancel() {
179+
cancelled = true;
180+
},
181+
}),
182+
{ headers: { "content-type": "application/json" } },
183+
),
184+
finalUrl: "https://provider.example.test/v1/models",
185+
release,
186+
}));
187+
188+
const error = await fetchLiveProviderModelIds({
189+
providerId: "provider",
190+
endpoint: "https://provider.example.test/v1/models",
191+
fetchGuard: fetchGuardMock,
192+
}).catch((err: unknown) => err);
193+
194+
expect(error).toBeInstanceOf(Error);
195+
expect((error as Error).message).toMatch(/Live model catalog response exceeded \d+ bytes/);
196+
expect(cancelled).toBe(true);
197+
expect(release).toHaveBeenCalledTimes(1);
198+
});
199+
200+
it("aborts a stalled live catalog success stream", async () => {
201+
vi.useFakeTimers();
202+
try {
203+
const encoder = new TextEncoder();
204+
let cancelReason: unknown;
205+
const release = vi.fn(async () => undefined);
206+
const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi.fn(async () => ({
207+
response: new Response(
208+
new ReadableStream<Uint8Array>({
209+
start(controller) {
210+
// Emit a partial JSON prefix and then idle forever without closing.
211+
controller.enqueue(encoder.encode('[{"id":"model-a",'));
212+
},
213+
cancel(reason) {
214+
cancelReason = reason;
215+
},
216+
}),
217+
{ headers: { "content-type": "application/json" } },
218+
),
219+
finalUrl: "https://provider.example.test/v1/models",
220+
release,
221+
}));
222+
223+
const resultPromise = fetchLiveProviderModelIds({
224+
providerId: "provider",
225+
endpoint: "https://provider.example.test/v1/models",
226+
fetchGuard: fetchGuardMock,
227+
timeoutMs: 1234,
228+
}).catch((err: unknown) => err);
229+
230+
await vi.advanceTimersByTimeAsync(0);
231+
await vi.advanceTimersByTimeAsync(1234);
232+
const error = await resultPromise;
233+
234+
expect(error).toBeInstanceOf(Error);
235+
expect((error as Error).message).toBe(
236+
"Live model catalog response stalled: no data received for 1234ms",
237+
);
238+
expect(cancelReason).toBeInstanceOf(Error);
239+
expect(release).toHaveBeenCalledTimes(1);
240+
} finally {
241+
vi.useRealTimers();
242+
}
243+
});
244+
160245
it("throws structured HTTP errors after releasing guarded fetches", async () => {
161246
const release = vi.fn(async () => undefined);
162247
const response = new Response("{}", { status: 401 });

src/plugin-sdk/provider-catalog-live-runtime.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readResponseWithLimit } from "@openclaw/media-core/read-response-with-limit";
12
import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js";
23
import {
34
clearLiveCatalogCacheForTests,
@@ -45,6 +46,15 @@ export type CachedLiveProviderModelRowsParams = FetchLiveProviderModelRowsParams
4546
shouldCacheRows?: (rows: readonly unknown[]) => boolean;
4647
};
4748

49+
// Live model catalogs are fetched at runtime from provider-controlled endpoints,
50+
// so the success body is untrusted just like the error body. A faulty or hostile
51+
// provider can stream an unbounded JSON document; reading it without a ceiling
52+
// lets a single discovery call exhaust process memory. The cap is sized well
53+
// above the largest known catalog (OpenRouter's live catalog is already >100KB
54+
// and grows) while still bounding memory, mirroring the bounded-stream hardening
55+
// applied to error bodies in #95108.
56+
const LIVE_MODEL_CATALOG_BODY_MAX_BYTES = 4 * 1024 * 1024;
57+
4858
export class LiveModelCatalogHttpError extends Error {
4959
readonly status: number;
5060

@@ -133,17 +143,29 @@ async function cancelUnreadResponseBody(response: Response): Promise<void> {
133143
}
134144
}
135145

146+
async function readLiveModelCatalogJson(response: Response, timeoutMs: number): Promise<unknown> {
147+
const buffer = await readResponseWithLimit(response, LIVE_MODEL_CATALOG_BODY_MAX_BYTES, {
148+
chunkTimeoutMs: timeoutMs,
149+
onOverflow: ({ size, maxBytes }) =>
150+
new Error(`Live model catalog response exceeded ${maxBytes} bytes (${size} bytes received)`),
151+
onIdleTimeout: ({ chunkTimeoutMs }) =>
152+
new Error(`Live model catalog response stalled: no data received for ${chunkTimeoutMs}ms`),
153+
});
154+
return JSON.parse(buffer.toString("utf8"));
155+
}
156+
136157
export async function fetchLiveProviderModelRows(
137158
params: FetchLiveProviderModelRowsParams,
138159
): Promise<readonly unknown[]> {
139160
const fetchGuard = params.fetchGuard ?? fetchWithSsrFGuard;
161+
const timeoutMs = params.timeoutMs ?? 5_000;
140162
const { response, release } = await fetchGuard({
141163
url: params.endpoint,
142164
init: {
143165
headers: buildHeaders(params),
144166
},
145167
signal: params.signal,
146-
timeoutMs: params.timeoutMs ?? 5_000,
168+
timeoutMs,
147169
policy: params.policy ?? ssrfPolicyFromHttpBaseUrlAllowedHostname(params.endpoint),
148170
...(params.lookupFn ? { lookupFn: params.lookupFn } : {}),
149171
...(params.requireHttps !== undefined ? { requireHttps: params.requireHttps } : {}),
@@ -154,7 +176,9 @@ export async function fetchLiveProviderModelRows(
154176
await cancelUnreadResponseBody(response);
155177
throw new LiveModelCatalogHttpError(params.providerId, response.status);
156178
}
157-
return (params.readRows ?? readDefaultLiveModelCatalogRows)(await response.json());
179+
return (params.readRows ?? readDefaultLiveModelCatalogRows)(
180+
await readLiveModelCatalogJson(response, timeoutMs),
181+
);
158182
} finally {
159183
await release();
160184
}

0 commit comments

Comments
 (0)