fix(proxy): preserve multipart form data

This commit is contained in:
Peter Steinberger
2026-05-02 09:20:36 +01:00
parent 09239a4622
commit b9c23547ee
3 changed files with 201 additions and 32 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Proxy/audio: convert standard `FormData` bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send `[object FormData]` when `HTTP_PROXY` or `HTTPS_PROXY` is configured. Fixes #48554. Thanks @dco5.
- Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma.
- Gateway/pricing: abort in-flight model pricing catalog fetches when Gateway shutdown stops the refresh loop, and avoid post-stop cache writes or refresh timers. Fixes #72208. Thanks @rzcq.
- Control UI/Talk: allow the OpenAI Realtime WebRTC offer endpoint through the Control UI CSP, configure browser sessions with explicit VAD/transcription input settings, and surface OpenAI realtime error/lifecycle events instead of leaving Talk stuck as live with no diagnostic. Fixes #73427.

View File

@@ -13,43 +13,64 @@ const ORIGINAL_PROXY_ENV = Object.fromEntries(
PROXY_ENV_KEYS.map((key) => [key, process.env[key]]),
) as Record<(typeof PROXY_ENV_KEYS)[number], string | undefined>;
const { ProxyAgent, EnvHttpProxyAgent, undiciFetch, proxyAgentSpy, envAgentSpy, getLastAgent } =
vi.hoisted(() => {
const undiciFetch = vi.fn();
const proxyAgentSpy = vi.fn();
const envAgentSpy = vi.fn();
class ProxyAgent {
static lastCreated: ProxyAgent | undefined;
proxyUrl: string;
constructor(proxyUrl: string) {
this.proxyUrl = proxyUrl;
ProxyAgent.lastCreated = this;
proxyAgentSpy(proxyUrl);
}
}
class EnvHttpProxyAgent {
static lastCreated: EnvHttpProxyAgent | undefined;
constructor(public readonly options?: Record<string, unknown>) {
EnvHttpProxyAgent.lastCreated = this;
envAgentSpy(options);
}
const {
ProxyAgent,
EnvHttpProxyAgent,
MockUndiciFormData,
undiciFetch,
proxyAgentSpy,
envAgentSpy,
getLastAgent,
} = vi.hoisted(() => {
const undiciFetch = vi.fn();
const proxyAgentSpy = vi.fn();
const envAgentSpy = vi.fn();
class MockUndiciFormData {
readonly [Symbol.toStringTag] = "FormData";
readonly entriesList: [string, unknown, string | undefined][] = [];
append(key: string, value: unknown, filename?: string): void {
this.entriesList.push([key, value, filename]);
}
return {
ProxyAgent,
EnvHttpProxyAgent,
undiciFetch,
proxyAgentSpy,
envAgentSpy,
getLastAgent: () => ProxyAgent.lastCreated,
};
});
get(key: string): unknown {
return this.entriesList.find(([entryKey]) => entryKey === key)?.[1] ?? null;
}
}
class ProxyAgent {
static lastCreated: ProxyAgent | undefined;
proxyUrl: string;
constructor(proxyUrl: string) {
this.proxyUrl = proxyUrl;
ProxyAgent.lastCreated = this;
proxyAgentSpy(proxyUrl);
}
}
class EnvHttpProxyAgent {
static lastCreated: EnvHttpProxyAgent | undefined;
constructor(public readonly options?: Record<string, unknown>) {
EnvHttpProxyAgent.lastCreated = this;
envAgentSpy(options);
}
}
return {
ProxyAgent,
EnvHttpProxyAgent,
MockUndiciFormData,
undiciFetch,
proxyAgentSpy,
envAgentSpy,
getLastAgent: () => ProxyAgent.lastCreated,
};
});
const mockedModuleIds = ["undici"] as const;
vi.mock("undici", () => ({
ProxyAgent,
EnvHttpProxyAgent,
FormData: MockUndiciFormData,
fetch: undiciFetch,
}));
@@ -112,6 +133,84 @@ describe("makeProxyFetch", () => {
expect(proxyAgentSpy).toHaveBeenCalledOnce();
expect(secondDispatcher).toBe(firstDispatcher);
});
it("converts global FormData bodies before dispatching through undici", async () => {
undiciFetch.mockResolvedValue({ ok: true });
const proxyFetch = makeProxyFetch("http://proxy.test:8080");
const form = new globalThis.FormData();
form.append("model", "whisper-1");
form.append("file", new Blob([new Uint8Array(4)], { type: "audio/ogg" }), "voice.ogg");
await proxyFetch("https://api.example.com/v1/audio/transcriptions", {
method: "POST",
headers: {
"content-length": "999",
"content-type": "multipart/form-data; boundary=stale",
},
body: form,
});
const passedInit = undiciFetch.mock.calls[0]?.[1];
expect(passedInit?.body).toBeInstanceOf(MockUndiciFormData);
const passedBody = passedInit?.body as InstanceType<typeof MockUndiciFormData>;
expect(passedBody.get("model")).toBe("whisper-1");
expect(passedBody.get("file")).toBeInstanceOf(Blob);
expect(passedBody.entriesList.find(([key]) => key === "file")?.[2]).toBe("voice.ogg");
const sentHeaders = new Headers(passedInit?.headers);
expect(sentHeaders.has("content-length")).toBe(false);
expect(sentHeaders.has("content-type")).toBe(false);
});
it("keeps non-FormData bodies unchanged", async () => {
undiciFetch.mockResolvedValue({ ok: true });
const proxyFetch = makeProxyFetch("http://proxy.test:8080");
const body = JSON.stringify({ hello: "world" });
await proxyFetch("https://api.example.com/json", {
method: "POST",
body,
});
expect(undiciFetch.mock.calls[0]?.[1]?.body).toBe(body);
});
it("keeps undici FormData instances unchanged", async () => {
undiciFetch.mockResolvedValue({ ok: true });
const proxyFetch = makeProxyFetch("http://proxy.test:8080");
const form = new MockUndiciFormData();
form.append("key", "value");
await proxyFetch("https://api.example.com/upload", {
method: "POST",
body: form as unknown as BodyInit,
});
expect(undiciFetch.mock.calls[0]?.[1]?.body).toBe(form);
});
it("converts FormData-like bodies from another implementation", async () => {
undiciFetch.mockResolvedValue({ ok: true });
const proxyFetch = makeProxyFetch("http://proxy.test:8080");
const formLike = {
[Symbol.toStringTag]: "FormData",
*entries(): IterableIterator<[string, FormDataEntryValue]> {
yield ["model", "whisper-1"];
},
};
await proxyFetch("https://api.example.com/upload", {
method: "POST",
body: formLike as unknown as BodyInit,
});
const passedInit = undiciFetch.mock.calls[0]?.[1];
expect(passedInit?.body).toBeInstanceOf(MockUndiciFormData);
expect(passedInit?.body.get("model")).toBe("whisper-1");
});
});
describe("getProxyUrlFromFetch", () => {
@@ -168,6 +267,30 @@ describe("resolveProxyFetchFromEnv", () => {
);
});
it("converts global FormData bodies when using proxy env fetch", async () => {
undiciFetch.mockResolvedValue({ ok: true });
const fetchFn = resolveProxyFetchFromEnv({
HTTP_PROXY: "",
HTTPS_PROXY: "http://proxy.test:8080",
});
expect(fetchFn).toBeDefined();
const form = new globalThis.FormData();
form.append("file", new Blob([new Uint8Array(8)], { type: "audio/wav" }), "test.wav");
form.append("model", "test-model");
await fetchFn!("https://api.example.com/v1/audio/transcriptions", {
method: "POST",
body: form,
});
const passedInit = undiciFetch.mock.calls[0]?.[1];
expect(passedInit?.body).toBeInstanceOf(MockUndiciFormData);
expect(passedInit?.body.get("model")).toBe("test-model");
expect(passedInit?.body.get("file")).toBeInstanceOf(Blob);
});
it("returns proxy fetch when HTTP_PROXY is set", () => {
const fetchFn = resolveProxyFetchFromEnv({
HTTPS_PROXY: "",

View File

@@ -1,4 +1,9 @@
import { EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici";
import {
EnvHttpProxyAgent,
FormData as UndiciFormData,
ProxyAgent,
fetch as undiciFetch,
} from "undici";
import { logWarn } from "../../logger.js";
import { formatErrorMessage } from "../errors.js";
import { resolveEnvHttpProxyAgentOptions } from "./proxy-env.js";
@@ -8,6 +13,46 @@ type ProxyFetchWithMetadata = typeof fetch & {
[PROXY_FETCH_PROXY_URL]?: string;
};
function isFormDataLike(value: unknown): value is FormData {
return (
typeof value === "object" &&
value !== null &&
typeof (value as FormData).entries === "function" &&
(value as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag] === "FormData"
);
}
function appendFormDataEntry(target: UndiciFormData, key: string, value: FormDataEntryValue): void {
if (typeof value === "string") {
target.append(key, value);
return;
}
const fileName = typeof value.name === "string" && value.name.trim() ? value.name : undefined;
if (fileName) {
target.append(key, value, fileName);
return;
}
target.append(key, value);
}
function normalizeInitForUndici(init: RequestInit | undefined): RequestInit | undefined {
if (!init) {
return init;
}
const body = init.body;
if (!isFormDataLike(body) || body instanceof UndiciFormData) {
return init;
}
const form = new UndiciFormData();
for (const [key, value] of body.entries()) {
appendFormDataEntry(form, key, value);
}
const headers = new Headers(init.headers);
headers.delete("content-length");
headers.delete("content-type");
return { ...init, headers, body: form as unknown as BodyInit };
}
/**
* Create a fetch function that routes requests through the given HTTP proxy.
* Uses undici's ProxyAgent under the hood.
@@ -24,7 +69,7 @@ export function makeProxyFetch(proxyUrl: string): typeof fetch {
// on stream/body internals. Single cast at the boundary keeps the rest type-safe.
const proxyFetch = ((input: RequestInfo | URL, init?: RequestInit) =>
undiciFetch(input as string | URL, {
...(init as Record<string, unknown>),
...(normalizeInitForUndici(init) as Record<string, unknown>),
dispatcher: resolveAgent(),
}) as unknown as Promise<Response>) as ProxyFetchWithMetadata;
Object.defineProperty(proxyFetch, PROXY_FETCH_PROXY_URL, {
@@ -62,7 +107,7 @@ export function resolveProxyFetchFromEnv(
const agent = new EnvHttpProxyAgent(proxyOptions);
return ((input: RequestInfo | URL, init?: RequestInit) =>
undiciFetch(input as string | URL, {
...(init as Record<string, unknown>),
...(normalizeInitForUndici(init) as Record<string, unknown>),
dispatcher: agent,
}) as unknown as Promise<Response>) as typeof fetch;
} catch (err) {