fix(extensions): guard model and Twilio fetches

This commit is contained in:
Peter Steinberger
2026-05-01 11:51:49 +01:00
parent b68f3de91b
commit ef832f83f6
4 changed files with 40 additions and 20 deletions

View File

@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { discoverKilocodeModels, KILOCODE_MODELS_URL } from "./provider-models.js";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
@@ -7,8 +6,13 @@ const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
ssrfPolicyFromHttpBaseUrlAllowedHostname: (baseUrl: string) => ({
allowedHostnames: [new URL(baseUrl).hostname],
}),
}));
import { discoverKilocodeModels, KILOCODE_MODELS_URL } from "./provider-models.js";
type MockKilocodeFetchResponse = {
ok: boolean;
status?: number;
@@ -79,9 +83,14 @@ async function withFetchPathTest(mockFetch: MockKilocodeFetch, runAssertions: ()
delete process.env.NODE_ENV;
delete process.env.VITEST;
fetchWithSsrFGuardMock.mockReset();
const callMockFetch = mockFetch as unknown as (
url: string,
init?: RequestInit,
) => Promise<unknown>;
fetchWithSsrFGuardMock.mockImplementation(
async (params: { url: string; init?: RequestInit }) => ({
response: await mockFetch(params.url, params.init),
response: await callMockFetch(params.url, params.init),
release,
}),
);
@@ -89,7 +98,6 @@ async function withFetchPathTest(mockFetch: MockKilocodeFetch, runAssertions: ()
try {
await runAssertions();
} finally {
fetchWithSsrFGuardMock.mockReset();
if (origNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
@@ -100,6 +108,7 @@ async function withFetchPathTest(mockFetch: MockKilocodeFetch, runAssertions: ()
} else {
process.env.VITEST = origVitest;
}
fetchWithSsrFGuardMock.mockReset();
}
}
@@ -141,6 +150,9 @@ describe("discoverKilocodeModels (fetch path)", () => {
init: expect.objectContaining({
headers: { Accept: "application/json" },
}),
policy: { allowedHostnames: ["api.kilo.ai"] },
timeoutMs: 5000,
auditContext: "kilocode.model_discovery",
}),
);
expect(mockFetch).toHaveBeenCalledWith(

View File

@@ -1,6 +1,9 @@
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
fetchWithSsrFGuard,
ssrfPolicyFromHttpBaseUrlAllowedHostname,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
const log = createSubsystemLogger("kilocode-models");
@@ -142,6 +145,7 @@ export async function discoverKilocodeModels(): Promise<ModelDefinitionConfig[]>
headers: { Accept: "application/json" },
},
timeoutMs: DISCOVERY_TIMEOUT_MS,
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(KILOCODE_BASE_URL),
auditContext: "kilocode.model_discovery",
});
try {

View File

@@ -1,22 +1,23 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
}));
vi.mock("../../../api.js", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
import { TwilioApiError, twilioApiRequest } from "./api.js";
const apiMocks = vi.hoisted(() => ({
fetchWithSsrFGuard: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: apiMocks.fetchWithSsrFGuard,
}));
describe("twilioApiRequest", () => {
afterEach(() => {
apiMocks.fetchWithSsrFGuard.mockReset();
fetchWithSsrFGuardMock.mockReset();
});
it("posts form bodies with basic auth and parses json", async () => {
const release = vi.fn(async () => {});
apiMocks.fetchWithSsrFGuard.mockResolvedValue({
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(JSON.stringify({ sid: "CA123" }), { status: 200 }),
release,
});
@@ -34,10 +35,12 @@ describe("twilioApiRequest", () => {
}),
).resolves.toEqual({ sid: "CA123" });
const [{ url, init, auditContext, policy }] = apiMocks.fetchWithSsrFGuard.mock.calls[0] ?? [];
const [{ url, init, auditContext, policy, timeoutMs }] =
fetchWithSsrFGuardMock.mock.calls[0] ?? [];
expect(url).toBe("https://api.twilio.com/Calls.json");
expect(auditContext).toBe("voice-call.twilio.api");
expect(policy).toEqual({ allowedHostnames: ["api.twilio.com"] });
expect(timeoutMs).toBe(30_000);
expect(init).toEqual(
expect.objectContaining({
method: "POST",
@@ -63,7 +66,7 @@ describe("twilioApiRequest", () => {
new Response("missing", { status: 404 }),
];
const release = vi.fn(async () => {});
apiMocks.fetchWithSsrFGuard.mockImplementation(async () => ({
fetchWithSsrFGuardMock.mockImplementation(async () => ({
response: responses.shift()!,
release,
}));
@@ -93,7 +96,7 @@ describe("twilioApiRequest", () => {
it("throws twilio api errors for non-ok responses", async () => {
const release = vi.fn(async () => {});
apiMocks.fetchWithSsrFGuard.mockResolvedValue({
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("bad request", { status: 400 }),
release,
});
@@ -112,7 +115,7 @@ describe("twilioApiRequest", () => {
it("exposes structured Twilio error codes from json error bodies", async () => {
const release = vi.fn(async () => {});
apiMocks.fetchWithSsrFGuard.mockResolvedValue({
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
JSON.stringify({
code: 21220,

View File

@@ -1,4 +1,4 @@
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { fetchWithSsrFGuard } from "../../../api.js";
type ParsedTwilioApiError = {
code?: number;
@@ -61,8 +61,9 @@ export async function twilioApiRequest<T = unknown>(params: {
return acc;
}, new URLSearchParams());
const requestUrl = `${params.baseUrl}${params.endpoint}`;
const { response, release } = await fetchWithSsrFGuard({
url: `${params.baseUrl}${params.endpoint}`,
url: requestUrl,
init: {
method: "POST",
headers: {